Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f94fbfe14e | ||
|
|
70ef2884f0 | ||
|
|
918d66fbdb | ||
|
|
5d1167a36c | ||
|
|
e3bf54e2ba | ||
|
|
d17640a283 | ||
|
|
9e889bd4dc | ||
|
|
ca4d8d5a7a | ||
|
|
5f54700344 | ||
|
|
0991f506bb | ||
|
|
fa77f168a0 | ||
|
|
3aa65809b1 | ||
|
|
9a66f16102 | ||
|
|
493870ad7b | ||
|
|
1e20bdd74e | ||
|
|
99a7830f6c | ||
|
|
e04e70db2e | ||
|
|
75517057d0 | ||
|
|
3efc91ff9a | ||
|
|
7c07c001cd | ||
|
|
f1f7f82e91 | ||
|
|
b3bd5d1fd1 | ||
|
|
ad9b368cb8 | ||
|
|
7c78b00df6 | ||
|
|
4dfb6e88af | ||
|
|
744797e59d | ||
|
|
689105c949 | ||
|
|
1332a30630 | ||
|
|
648e5608b7 | ||
|
|
42dffaa0c8 | ||
|
|
ec3359f1cb | ||
|
|
235e57bf77 | ||
|
|
630240971f | ||
|
|
44e312cfba | ||
|
|
67c158a0ab | ||
|
|
1554f0d439 | ||
|
|
3e250d7878 | ||
|
|
a6d63285b7 | ||
|
|
c1552140b0 | ||
|
|
238617e566 | ||
|
|
3f3671fbe5 | ||
|
|
01ba579766 | ||
|
|
e2864b6664 | ||
|
|
1756d70fda | ||
|
|
b12be9119f | ||
|
|
e66d5a70e7 | ||
|
|
1a81902a36 | ||
|
|
ec5b4eb939 | ||
|
|
3457823b5b | ||
|
|
ffbe3d1681 | ||
|
|
5c0f0b498a | ||
|
|
c46f748412 | ||
|
|
59be6583b8 | ||
|
|
aeefb962d4 | ||
|
|
f80be1030b | ||
|
|
7df744b684 | ||
|
|
c66634fcfd | ||
|
|
d92248c664 | ||
|
|
67152772d2 | ||
|
|
564b720bce | ||
|
|
6aaebd167d | ||
|
|
2d236024bc | ||
|
|
e09b352067 | ||
|
|
4c89b0f7df | ||
|
|
b020f12f41 | ||
|
|
43848a7d68 | ||
|
|
9e96fdc627 | ||
|
|
434b7efd66 | ||
|
|
814cc14f88 | ||
|
|
59bf69f1d5 |
@@ -9,6 +9,8 @@ body:
|
||||
Stick to only a single feature per request. If you list multiple different features at once,
|
||||
your request will be closed.
|
||||
|
||||
Please read our [contributing guidelines](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md) before opening a feature request.
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: I have searched the existing feature requests, both open and closed, to make sure this is not a duplicate request.
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
name: Report an issue with Immich
|
||||
description: Report an issue with Immich
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please read our [contributing guidelines](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md) before opening an issue.
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: I have searched the existing issues, both open and closed, to make sure this is not a duplicate report.
|
||||
|
||||
@@ -204,7 +204,7 @@ jobs:
|
||||
contents: read
|
||||
# Run on main branch or workflow_dispatch, or on PRs/other branches (build only, no upload)
|
||||
if: ${{ !github.event.pull_request.head.repo.fork && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
|
||||
runs-on: macos-26
|
||||
runs-on: xcode-27
|
||||
|
||||
steps:
|
||||
- id: token
|
||||
@@ -214,8 +214,8 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
permission-contents: read
|
||||
|
||||
- name: Select Xcode 26
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
|
||||
- name: Select Xcode 27.1
|
||||
run: sudo xcode-select -s /Applications/Xcode_27.1.app/Contents/Developer
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -230,7 +230,7 @@ jobs:
|
||||
working_directory: ./mobile
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0
|
||||
uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0
|
||||
with:
|
||||
ruby-version: '3.4'
|
||||
bundler-cache: true # zizmor: ignore[cache-poisoning] release branches cannot read caches written by pull requests
|
||||
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Generate a token
|
||||
id: generate_token
|
||||
if: ${{ inputs.skip != true }}
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
@@ -22,3 +22,4 @@ jobs:
|
||||
- uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
sync-labels: true
|
||||
|
||||
+16
-26
@@ -1,43 +1,33 @@
|
||||
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
|
||||
|
||||
[[tools.opentofu]]
|
||||
version = "1.12.6"
|
||||
version = "1.13.1"
|
||||
backend = "aqua:opentofu/opentofu"
|
||||
|
||||
[tools.opentofu."platforms.linux-arm64"]
|
||||
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"
|
||||
|
||||
[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: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-x64"]
|
||||
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"
|
||||
|
||||
[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: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.macos-arm64"]
|
||||
checksum = "sha256:f958ec5e511063be9feb180ca015a4cb7977566a9cf6a8550bba8c2a9b5aba74"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_darwin_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762710"
|
||||
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"
|
||||
|
||||
[tools.opentofu."platforms.macos-x64"]
|
||||
checksum = "sha256:44bb1855f372f17f365fb94517906e78da5001da10f4c98de57a39bf982f3a92"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_darwin_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762655"
|
||||
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"
|
||||
|
||||
[tools.opentofu."platforms.windows-x64"]
|
||||
checksum = "sha256:bcbcd9ca744af6c4053e2d5e75dae79ff33ad7dc38d45e1233e693d096284538"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_windows_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762708"
|
||||
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"
|
||||
|
||||
[[tools.terragrunt]]
|
||||
version = "1.0.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tools]
|
||||
terragrunt = "1.1.1"
|
||||
opentofu = "1.12.6"
|
||||
opentofu = "1.13.1"
|
||||
|
||||
[tasks."tg:fmt"]
|
||||
run = "terragrunt hclfmt"
|
||||
|
||||
@@ -97,7 +97,7 @@ services:
|
||||
command: ['./run.sh', '-disable-reporting']
|
||||
ports:
|
||||
- 3000:3000
|
||||
image: grafana/grafana:12.4.11-ubuntu@sha256:23f2328b859c7677c7065c81b479600706ef9cc9004977af5bbd68b159b8ea60
|
||||
image: grafana/grafana:13.2.4-ubuntu@sha256:d838696907b4350d9232eb8da1503dc1d1010a0359d3ce4b3f7d2ff7db28eb75
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
|
||||
|
||||
@@ -209,6 +209,11 @@ The default configuration looks like this:
|
||||
"timeout": 30000,
|
||||
"tokenEndpointAuthMethod": "client_secret_post"
|
||||
},
|
||||
"passkey": {
|
||||
"enabled": true,
|
||||
"domain": null,
|
||||
"additionalDomains": []
|
||||
},
|
||||
"passwordLogin": {
|
||||
"enabled": true
|
||||
},
|
||||
|
||||
+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.137.0"
|
||||
version = "4.151.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.137.0"
|
||||
wrangler = "4.151.0"
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^75.0.0",
|
||||
"exiftool-vendored": "^35.0.0",
|
||||
"exiftool-vendored": "^39.0.0",
|
||||
"globals": "^17.0.0",
|
||||
"luxon": "^3.4.4",
|
||||
"pg": "^8.11.3",
|
||||
|
||||
@@ -567,7 +567,7 @@ describe('/search', () => {
|
||||
'New York',
|
||||
'Shanghai',
|
||||
'State of Berlin',
|
||||
'St.-Petersburg',
|
||||
'St. Petersburg',
|
||||
'Tbilisi',
|
||||
'Tokyo',
|
||||
'Virginia',
|
||||
@@ -591,7 +591,7 @@ describe('/search', () => {
|
||||
'New York',
|
||||
'Shanghai',
|
||||
'State of Berlin',
|
||||
'St.-Petersburg',
|
||||
'St. Petersburg',
|
||||
'Tbilisi',
|
||||
'Tokyo',
|
||||
'Virginia',
|
||||
|
||||
@@ -206,6 +206,13 @@ describe('/shared-links', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not return album users', async () => {
|
||||
const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithAlbum.key });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.album.albumUsers).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return unauthorized for incorrect shared link', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/shared-links/me')
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { LoginResponseDto, getMyUser, login } from '@immich/sdk';
|
||||
import { createUserDto } from 'src/fixtures.js';
|
||||
import { app, asBearerAuth, utils } from 'src/utils.js';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/users', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let nonAdmin: LoginResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup({ onboarding: false });
|
||||
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user2);
|
||||
});
|
||||
|
||||
describe('PUT /users/me', () => {
|
||||
/** @deprecated */
|
||||
it('should allow a user to change their password (deprecated)', async () => {
|
||||
const user = await getMyUser({ headers: asBearerAuth(nonAdmin.accessToken) });
|
||||
|
||||
expect(user.shouldChangePassword).toBe(true);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.put(`/users/me`)
|
||||
.send({ password: 'super-secret' })
|
||||
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({
|
||||
email: nonAdmin.userEmail,
|
||||
shouldChangePassword: false,
|
||||
});
|
||||
|
||||
const token = await login({ loginCredentialDto: { email: nonAdmin.userEmail, password: 'super-secret' } });
|
||||
|
||||
expect(token.accessToken).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ test.describe('Registration', () => {
|
||||
await page.goto('/auth/login?autoLaunch=0');
|
||||
await page.getByLabel('Email').fill('admin@immich.app');
|
||||
await page.getByLabel('Password', { exact: true }).fill('password');
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
await page.getByRole('button', { name: 'Login', exact: true }).click();
|
||||
|
||||
// onboarding
|
||||
await expect(page).toHaveURL('/auth/onboarding');
|
||||
@@ -66,11 +66,12 @@ test.describe('Registration', () => {
|
||||
await page.goto('/auth/login?autoLaunch=0');
|
||||
await page.getByLabel('Email').fill('user@immich.cloud');
|
||||
await page.getByLabel('Password').fill('password');
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
await page.getByRole('button', { name: 'Login', exact: true }).click();
|
||||
|
||||
// change password
|
||||
await expect(page.getByRole('heading')).toHaveText('Change Password');
|
||||
await expect(page).toHaveURL('/auth/change-password');
|
||||
await page.getByLabel('Password', { exact: true }).fill('password');
|
||||
await page.getByLabel('New Password').fill('new-password');
|
||||
await page.getByLabel('Confirm Password').fill('new-password');
|
||||
await page.getByRole('button', { name: 'Change password' }).click();
|
||||
@@ -79,7 +80,7 @@ test.describe('Registration', () => {
|
||||
await expect(page).toHaveURL('/auth/login?autoLaunch=0');
|
||||
await page.getByLabel('Email').fill('user@immich.cloud');
|
||||
await page.getByLabel('Password').fill('new-password');
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
await page.getByRole('button', { name: 'Login', exact: true }).click();
|
||||
|
||||
// onboarding
|
||||
await expect(page).toHaveURL('/auth/onboarding');
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -324,6 +324,13 @@
|
||||
"oauth_timeout": "Request Timeout",
|
||||
"oauth_timeout_description": "Timeout for requests in milliseconds",
|
||||
"ocr_job_description": "Use machine learning to recognize text in images",
|
||||
"passkey_additional_domains": "Additional domains",
|
||||
"passkey_additional_domains_description": "Additional, trusted domains (related origin requests). These will only work if the passkey domain is also accessible.",
|
||||
"passkey_domain": "Passkey domain",
|
||||
"passkey_domain_description": "Domain used for passkeys (relying party). Leave blank to infer from the origin header. Changing this value will result in existing passkeys no longer working.",
|
||||
"passkey_enable_description": "Login with a passkey",
|
||||
"passkey_settings": "Passkey Login",
|
||||
"passkey_settings_description": "Manage passkey login settings",
|
||||
"password_enable_description": "Login with email and password",
|
||||
"password_settings": "Password Login",
|
||||
"password_settings_description": "Manage password login settings",
|
||||
@@ -874,6 +881,8 @@
|
||||
"delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only",
|
||||
"delete_local_dialog_ok_force": "Delete Anyway",
|
||||
"delete_others": "Delete others",
|
||||
"delete_passkey": "Delete passkey",
|
||||
"delete_passkey_prompt": "Are you sure you want to delete this passkey?",
|
||||
"delete_permanently": "Delete permanently",
|
||||
"delete_permanently_action_prompt": "{count} deleted permanently",
|
||||
"delete_shared_link": "Delete shared link",
|
||||
@@ -943,6 +952,7 @@
|
||||
"edit_location_action_prompt": "{count} location edited",
|
||||
"edit_location_dialog_title": "Location",
|
||||
"edit_name": "Edit name",
|
||||
"edit_passkey": "Edit passkey",
|
||||
"edit_people": "Edit people",
|
||||
"edit_person": "Edit person",
|
||||
"edit_tag": "Edit tag",
|
||||
@@ -1287,6 +1297,7 @@
|
||||
"last": "Last",
|
||||
"last_months": "{count, plural, one {Last month} other {Last # months}}",
|
||||
"last_seen": "Last seen",
|
||||
"last_used": "Last used",
|
||||
"latest_version": "Latest Version",
|
||||
"latitude": "Latitude",
|
||||
"leave": "Leave",
|
||||
@@ -1379,6 +1390,7 @@
|
||||
"login_has_been_disabled": "Login has been disabled",
|
||||
"login_password_changed_error": "There was an error updating your password",
|
||||
"login_password_changed_success": "Password updated successfully",
|
||||
"login_with_passkey": "Login with passkey",
|
||||
"logout_all_device_confirmation": "Are you sure you want to log out all devices?",
|
||||
"logout_this_device_confirmation": "Are you sure you want to log out this device?",
|
||||
"logs": "Logs",
|
||||
@@ -1427,6 +1439,7 @@
|
||||
"manage_your_api_keys": "Manage your API keys",
|
||||
"manage_your_devices": "Manage your logged-in devices",
|
||||
"manage_your_oauth_connection": "Manage your OAuth connection",
|
||||
"manage_your_passkeys": "Manage your passkeys",
|
||||
"map": "Map",
|
||||
"map_assets_in_bounds": "{count, plural, =0 {No photos in this area} one {# photo} other {# photos}}",
|
||||
"map_cannot_get_user_location": "Cannot get user's location",
|
||||
@@ -1546,6 +1559,7 @@
|
||||
"new_album": "New Album",
|
||||
"new_api_key": "New API Key",
|
||||
"new_feature": "New Feature",
|
||||
"new_passkey": "New passkey",
|
||||
"new_password": "New password",
|
||||
"new_person": "New person",
|
||||
"new_pin_code": "New PIN code",
|
||||
@@ -1658,6 +1672,8 @@
|
||||
"partner_sharing": "Partner Sharing",
|
||||
"partners": "Partners",
|
||||
"partners_description": "Share your photos and videos with other users",
|
||||
"passkeys": "{count, plural, one {Passkey} other {Passkeys}}",
|
||||
"passkeys_not_supported": "Passkeys are not supported by this browser",
|
||||
"password": "Password",
|
||||
"password_does_not_match": "Password does not match",
|
||||
"password_required": "Password Required",
|
||||
@@ -1842,6 +1858,7 @@
|
||||
"removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites",
|
||||
"removed_from_stack": "Removed asset from stack",
|
||||
"removed_memory": "Removed memory",
|
||||
"removed_passkey": "Removed passkey: {name}",
|
||||
"removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}",
|
||||
"rename": "Rename",
|
||||
"repository": "Repository",
|
||||
@@ -2025,6 +2042,7 @@
|
||||
"share_everyone": "Share everyone",
|
||||
"share_everyone_description": "Give {user} access to all of your people.",
|
||||
"share_link": "Share Link",
|
||||
"share_logs": "Share Logs",
|
||||
"share_original": "Use original (large)",
|
||||
"share_preview": "Use preview (small)",
|
||||
"share_quality_body": "Press and hold the share button to choose the image quality before you share.",
|
||||
@@ -2203,6 +2221,8 @@
|
||||
"theme": "Theme",
|
||||
"theme_selection": "Theme selection",
|
||||
"theme_selection_description": "Automatically set the theme to light or dark based on your browser's system preference",
|
||||
"theme_setting_asset_list_smooth_scrubbing_subtitle": "Scroll continuously when dragging the scrollbar instead of snapping to months",
|
||||
"theme_setting_asset_list_smooth_scrubbing_title": "Smooth timeline scrubbing",
|
||||
"theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles",
|
||||
"theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({count})",
|
||||
"theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.",
|
||||
|
||||
@@ -323,6 +323,13 @@
|
||||
"oauth_timeout": "Таймаут для запросов",
|
||||
"oauth_timeout_description": "Максимальное время, в течение которого ожидать ответа, в миллисекундах",
|
||||
"ocr_job_description": "Использование машинного обучения для распознавания текста на изображениях",
|
||||
"passkey_additional_domains": "Дополнительные домены",
|
||||
"passkey_additional_domains_description": "Дополнительные доверенные домены. Для их работы основной домен ключей доступа также должен быть доступен.",
|
||||
"passkey_domain": "Домен ключей доступа",
|
||||
"passkey_domain_description": "Домен для ключей доступа. Оставьте пустым для определения по адресу сайта. При изменении домена существующие ключи перестанут работать.",
|
||||
"passkey_enable_description": "Вход с помощью ключа доступа",
|
||||
"passkey_settings": "Вход по ключу доступа",
|
||||
"passkey_settings_description": "Настройки входа по ключу доступа",
|
||||
"password_enable_description": "Вход по электронной почте и паролю",
|
||||
"password_settings": "Настройки входа с паролем",
|
||||
"password_settings_description": "Управление настройками входа по паролю",
|
||||
@@ -873,6 +880,8 @@
|
||||
"delete_local_dialog_ok_backed_up_only": "Удалить только резервные копии",
|
||||
"delete_local_dialog_ok_force": "Все равно удалить",
|
||||
"delete_others": "Удалить остальные",
|
||||
"delete_passkey": "Удалить ключ доступа",
|
||||
"delete_passkey_prompt": "Удалить этот ключ доступа?",
|
||||
"delete_permanently": "Удалить навсегда",
|
||||
"delete_permanently_action_prompt": "Объекты удалены безвозвратно ({count} шт.)",
|
||||
"delete_shared_link": "Удалить публичную ссылку",
|
||||
@@ -942,6 +951,7 @@
|
||||
"edit_location_action_prompt": "Места изменены ({count} шт.)",
|
||||
"edit_location_dialog_title": "Местоположение",
|
||||
"edit_name": "Изменить имя",
|
||||
"edit_passkey": "Изменить ключ доступа",
|
||||
"edit_people": "Изменить людей",
|
||||
"edit_person": "Изменить человека",
|
||||
"edit_tag": "Изменить тег",
|
||||
@@ -1287,6 +1297,7 @@
|
||||
"last": "Последний",
|
||||
"last_months": "{count, plural, one {Последний месяц} many {# последних месяцев} other {# последних месяца}}",
|
||||
"last_seen": "Последний доступ",
|
||||
"last_used": "Последнее использование",
|
||||
"latest_version": "Последняя версия",
|
||||
"latitude": "Широта",
|
||||
"leave": "Покинуть",
|
||||
@@ -1379,6 +1390,7 @@
|
||||
"login_has_been_disabled": "Вход в систему был отключён",
|
||||
"login_password_changed_error": "Произошла ошибка при обновлении пароля",
|
||||
"login_password_changed_success": "Пароль успешно обновлен",
|
||||
"login_with_passkey": "Войти с ключом доступа",
|
||||
"logout_all_device_confirmation": "Вы действительно хотите завершить все сеансы, кроме текущего?",
|
||||
"logout_this_device_confirmation": "Вы действительно хотите завершить сеанс на этом устройстве?",
|
||||
"logs": "Журнал событий",
|
||||
@@ -1427,6 +1439,7 @@
|
||||
"manage_your_api_keys": "Управление API ключами для взаимодействия с другими программами",
|
||||
"manage_your_devices": "Управление устройствами, на которых вы входили в свой аккаунт",
|
||||
"manage_your_oauth_connection": "Настройки подключённого OAuth",
|
||||
"manage_your_passkeys": "Управление ключами доступа",
|
||||
"map": "Карта",
|
||||
"map_assets_in_bounds": "{count, plural, =0 {Нет фото в этой области} other {# фото}}",
|
||||
"map_cannot_get_user_location": "Невозможно получить местоположение пользователя",
|
||||
@@ -1546,6 +1559,7 @@
|
||||
"new_album": "Новый альбом",
|
||||
"new_api_key": "Новый API ключ",
|
||||
"new_feature": "Новая функциональность",
|
||||
"new_passkey": "Новый ключ доступа",
|
||||
"new_password": "Новый пароль",
|
||||
"new_person": "Новый человек",
|
||||
"new_pin_code": "Новый PIN-код",
|
||||
@@ -1658,6 +1672,8 @@
|
||||
"partner_sharing": "Совместное использование",
|
||||
"partners": "Партнёры",
|
||||
"partners_description": "Делитесь своими фото и видео с другими пользователями",
|
||||
"passkeys": "{count, plural, one {Ключ доступа} few {Ключи доступа} many {Ключи доступа} other {Ключи доступа}}",
|
||||
"passkeys_not_supported": "Этот браузер не поддерживает ключи доступа",
|
||||
"password": "Пароль",
|
||||
"password_does_not_match": "Пароли не совпадают",
|
||||
"password_required": "Требуется пароль",
|
||||
@@ -1842,6 +1858,7 @@
|
||||
"removed_from_favorites_count": "{count, plural, one {# объект удалён} many {# объектов удалено} other {# объекта удалено}} из избранного",
|
||||
"removed_from_stack": "Объект удалён из группы",
|
||||
"removed_memory": "Воспоминание удалено",
|
||||
"removed_passkey": "Удалён ключ доступа: {name}",
|
||||
"removed_tagged_assets": "Тег удалён у {count, plural, one {# объекта} other {# объектов}}",
|
||||
"rename": "Переименовать",
|
||||
"repository": "Репозиторий",
|
||||
@@ -2027,6 +2044,7 @@
|
||||
"share_everyone": "Поделиться со всеми",
|
||||
"share_everyone_description": "Предоставить пользователю {user} доступ всем своим людям.",
|
||||
"share_link": "Создать ссылку",
|
||||
"share_logs": "Поделиться журналами",
|
||||
"share_original": "Оригинал (большой размер)",
|
||||
"share_preview": "Предпросмотр (небольшой размер)",
|
||||
"share_quality_body": "Нажмите и удерживайте кнопку «Поделиться», чтобы выбрать качество изображения перед отправкой.",
|
||||
@@ -2206,6 +2224,8 @@
|
||||
"theme": "Тема",
|
||||
"theme_selection": "Выбор темы",
|
||||
"theme_selection_description": "Автоматически устанавливать светлую или тёмную тему в зависимости от настроек вашего браузера",
|
||||
"theme_setting_asset_list_smooth_scrubbing_subtitle": "Прокручивать ленту плавно при перетаскивании полосы прокрутки, без переходов по месяцам",
|
||||
"theme_setting_asset_list_smooth_scrubbing_title": "Плавная прокрутка ленты",
|
||||
"theme_setting_asset_list_storage_indicator_title": "Отображать индикатор хранилища на плитках объектов",
|
||||
"theme_setting_asset_list_tiles_per_row_title": "Количество объектов в строке ({count})",
|
||||
"theme_setting_colorful_interface_subtitle": "Добавить оттенок к фону.",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
ARG DEVICE=cpu
|
||||
|
||||
FROM python:3.13-slim-trixie@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285 AS builder-cpu
|
||||
FROM python:3.13-slim-trixie@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f AS builder-cpu
|
||||
|
||||
FROM python:3.13-slim-trixie@sha256:7c61056e61ac89e852de05f3dc6fa51a6dd2181797bceed46aa725dd7cb2cd3b AS builder-openvino
|
||||
FROM python:3.13-slim-trixie@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f AS builder-openvino
|
||||
|
||||
# renovate: datasource=github-releases depName=microsoft/onnxruntime
|
||||
ARG ONNXRUNTIME_VERSION="v1.30.0"
|
||||
@@ -59,7 +59,7 @@ RUN mkdir /opt/armnn && \
|
||||
cd /opt/ann && \
|
||||
sh build.sh
|
||||
|
||||
FROM python:3.12-slim-trixie@sha256:78387bc3881b8273120a12ebe6c1ab22b018ccc2c9adf565ae1ac9b536e184ea AS builder-rknn
|
||||
FROM python:3.12-slim-trixie@sha256:cb33f7f771ecec2ef1225cf39b159591a040391bd73e4c01f00fdfc4c47611d2 AS builder-rknn
|
||||
|
||||
# Warning: 25GiB+ disk space required to pull this image
|
||||
# TODO: find a way to reduce the image size
|
||||
@@ -81,11 +81,11 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --extra ${DEVICE} --no-dev --no-editable --no-install-project --compile-bytecode --no-progress --active --link-mode copy && \
|
||||
if [ "$DEVICE" = "openvino" ]; then uv pip install --no-deps /opt/onnxruntime_openvino-*.whl; fi
|
||||
|
||||
FROM python:3.13-slim-trixie@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285 AS prod-cpu
|
||||
FROM python:3.13-slim-trixie@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f AS prod-cpu
|
||||
|
||||
ENV MACHINE_LEARNING_MODEL_ARENA=false
|
||||
|
||||
FROM python:3.13-slim-trixie@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285 AS prod-openvino
|
||||
FROM python:3.13-slim-trixie@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f AS prod-openvino
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||
@@ -141,7 +141,7 @@ COPY --from=builder-armnn \
|
||||
/opt/ann/build.sh \
|
||||
/opt/armnn/
|
||||
|
||||
FROM python:3.12-slim-trixie@sha256:78387bc3881b8273120a12ebe6c1ab22b018ccc2c9adf565ae1ac9b536e184ea AS prod-rknn
|
||||
FROM python:3.12-slim-trixie@sha256:cb33f7f771ecec2ef1225cf39b159591a040391bd73e4c01f00fdfc4c47611d2 AS prod-rknn
|
||||
|
||||
# renovate: datasource=github-tags depName=airockchip/rknn-toolkit2
|
||||
ARG RKNN_TOOLKIT_VERSION="v2.3.0"
|
||||
@@ -150,7 +150,7 @@ ENV MACHINE_LEARNING_MODEL_ARENA=false
|
||||
|
||||
ADD --chmod=644 --checksum=sha256:73993ed4b440460825f21611731564503cc1d5a0c123746477da6cd574f34885 "https://github.com/airockchip/rknn-toolkit2/raw/refs/tags/${RKNN_TOOLKIT_VERSION}/rknpu2/runtime/Linux/librknn_api/aarch64/librknnrt.so" /usr/lib/
|
||||
|
||||
FROM python:3.13-slim-trixie@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285 AS mimalloc
|
||||
FROM python:3.13-slim-trixie@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f AS mimalloc
|
||||
RUN echo "deb http://deb.debian.org/debian trixie-backports main" > /etc/apt/sources.list.d/backports.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends libmimalloc3/trixie-backports && \
|
||||
|
||||
@@ -3,6 +3,7 @@ from collections.abc import Hashable
|
||||
from typing import Any
|
||||
|
||||
from immich_ml import allocator
|
||||
from immich_ml.config import clean_name
|
||||
from immich_ml.models.base import InferenceEntry, InferenceModel
|
||||
from immich_ml.schemas import Options
|
||||
|
||||
@@ -15,7 +16,8 @@ class ModelCache:
|
||||
self._expiries: dict[Key, asyncio.TimerHandle] = {}
|
||||
|
||||
def get[O: Options](self, entry: InferenceEntry[O], ttl: int | None = None) -> InferenceModel[O]:
|
||||
key = (entry.model, entry.name, entry.model.graph(entry.options))
|
||||
# names that differ only in what cleaning drops, like an `immich-app/` prefix, are the same model
|
||||
key = (entry.model, clean_name(entry.name), entry.model.graph(entry.options))
|
||||
if key not in self._models:
|
||||
self._models[key] = entry.model.create(entry.name, entry.options)
|
||||
elif key not in self._expiries:
|
||||
|
||||
@@ -1856,6 +1856,14 @@ class TestCache:
|
||||
assert len(model_cache._models) == 1
|
||||
model.create.assert_called_once()
|
||||
|
||||
async def test_shares_an_instance_across_names_of_the_same_model(self) -> None:
|
||||
model_cache = ModelCache()
|
||||
bare = model_cache.get(InferenceEntry(OpenClipTextualEncoder, "ViT-B-32__openai", TextualOptions()))
|
||||
prefixed = model_cache.get(
|
||||
InferenceEntry(OpenClipTextualEncoder, "immich-app/ViT-B-32__openai", TextualOptions(language="en-US"))
|
||||
)
|
||||
assert bare is prefixed
|
||||
|
||||
async def test_separate_instances_per_graph_option(self) -> None:
|
||||
# the real TextDetector, so that keying it on anything but its resolution fails this
|
||||
model_cache = ModelCache()
|
||||
|
||||
Generated
+3
-3
@@ -175,11 +175,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.11.12"
|
||||
version = "2026.10.19"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -212,36 +212,37 @@ checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882
|
||||
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
|
||||
|
||||
[[tools.node]]
|
||||
version = "24.21.0"
|
||||
version = "24.22.1"
|
||||
backend = "core:node"
|
||||
specifiers = ["24.22.1"]
|
||||
|
||||
[tools.node."platforms.linux-arm64"]
|
||||
checksum = "sha256:724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5"
|
||||
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-arm64.tar.gz"
|
||||
checksum = "sha256:33c37be234ba8bee041961047b19160f4d7d0f164e8e246f78bdd10feff148b3"
|
||||
url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-linux-arm64.tar.gz"
|
||||
|
||||
[tools.node."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:3048b0811e158ca0d8672b59c839861763e144492980d8d29b369dfee45747e4"
|
||||
url = "https://unofficial-builds.nodejs.org/download/release/v24.21.0/node-v24.21.0-linux-arm64-musl.tar.gz"
|
||||
|
||||
[tools.node."platforms.linux-x64"]
|
||||
checksum = "sha256:6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff"
|
||||
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-x64.tar.gz"
|
||||
checksum = "sha256:eb6728e77c576a35162c122c45ccadf224d89ff00abc8dae46cc2897c6002d7c"
|
||||
url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-linux-x64.tar.gz"
|
||||
|
||||
[tools.node."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:01a713e34da98e7b4d2a3191ee7ca3f93dd0384d1a2ef23c379b2161ee6e133a"
|
||||
url = "https://unofficial-builds.nodejs.org/download/release/v24.21.0/node-v24.21.0-linux-x64-musl.tar.gz"
|
||||
|
||||
[tools.node."platforms.macos-arm64"]
|
||||
checksum = "sha256:bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057"
|
||||
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-arm64.tar.gz"
|
||||
checksum = "sha256:89c682593d76cd44233fa53b1563264e7de9cbb189cad169135c5b4fadfbc0a7"
|
||||
url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-darwin-arm64.tar.gz"
|
||||
|
||||
[tools.node."platforms.macos-x64"]
|
||||
checksum = "sha256:1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097"
|
||||
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-x64.tar.gz"
|
||||
checksum = "sha256:d132557e7ee2b85af08b0c8c34338e65c6430f2238d01f727728ef42dc83b035"
|
||||
url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-darwin-x64.tar.gz"
|
||||
|
||||
[tools.node."platforms.windows-x64"]
|
||||
checksum = "sha256:158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541"
|
||||
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-x64.zip"
|
||||
checksum = "sha256:d2046d28c2c3dcd211ac5145c0bf30e2047ef9dfb306693c8c6e2eccd8b95fa5"
|
||||
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"
|
||||
@@ -252,13 +253,14 @@ version = "7.5.0"
|
||||
backend = "npm:oazapfts"
|
||||
|
||||
[[tools.opentofu]]
|
||||
version = "1.12.6"
|
||||
version = "1.13.1"
|
||||
backend = "aqua:opentofu/opentofu"
|
||||
specifiers = ["1.13.1"]
|
||||
|
||||
[tools.opentofu."platforms.linux-arm64"]
|
||||
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: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"
|
||||
@@ -266,9 +268,9 @@ url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762688"
|
||||
|
||||
[tools.opentofu."platforms.linux-x64"]
|
||||
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: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"
|
||||
@@ -276,58 +278,53 @@ url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762723"
|
||||
|
||||
[tools.opentofu."platforms.macos-arm64"]
|
||||
checksum = "sha256:f958ec5e511063be9feb180ca015a4cb7977566a9cf6a8550bba8c2a9b5aba74"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_darwin_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762710"
|
||||
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"
|
||||
|
||||
[tools.opentofu."platforms.macos-x64"]
|
||||
checksum = "sha256:44bb1855f372f17f365fb94517906e78da5001da10f4c98de57a39bf982f3a92"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_darwin_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762655"
|
||||
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"
|
||||
|
||||
[tools.opentofu."platforms.windows-x64"]
|
||||
checksum = "sha256:bcbcd9ca744af6c4053e2d5e75dae79ff33ad7dc38d45e1233e693d096284538"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_windows_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762708"
|
||||
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"
|
||||
|
||||
[[tools.pnpm]]
|
||||
version = "11.27.1"
|
||||
version = "12.14.0"
|
||||
backend = "aqua:pnpm/pnpm"
|
||||
specifiers = ["12.14.0"]
|
||||
|
||||
[tools.pnpm."platforms.linux-arm64"]
|
||||
checksum = "sha256:dbf8f84263d2bc696306d4219be68256d881d9af409345374d878676a7edcf0c"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-linux-arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602580"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:7a7854e90bdb7d339430d9943ea482b34d7bde817cfc1c749d3c04ddd464142d"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-linux-arm64-musl.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602583"
|
||||
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"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.linux-x64"]
|
||||
checksum = "sha256:6be1426540406747b7731564a46c5b30d3023ea6c19e4ce99f994c4ec122f3d2"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-linux-x64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602576"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:cc66c1558a1a03b320ff5b4022f755386fa1de54db3f97ff624112015be1ccf1"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-linux-x64-musl.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602579"
|
||||
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"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.macos-arm64"]
|
||||
checksum = "sha256:352f67ceaaa5aba294a53f483da93c376005b4c7e785fe5411e0ce720fe74741"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-darwin-arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602575"
|
||||
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"
|
||||
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"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.windows-x64"]
|
||||
checksum = "sha256:4b56d1184c3390b08146060fa4b414b8ceec9164aa41bf996e87a0f0bc135851"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-win32-x64.zip"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602578"
|
||||
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"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[[tools.terragrunt]]
|
||||
|
||||
@@ -15,10 +15,10 @@ config_roots = [
|
||||
]
|
||||
|
||||
[tools]
|
||||
node = "24.21.0"
|
||||
pnpm = "11.27.1"
|
||||
node = "24.22.1"
|
||||
pnpm = "12.14.0"
|
||||
terragrunt = "1.1.1"
|
||||
opentofu = "1.12.6"
|
||||
opentofu = "1.13.1"
|
||||
"npm:@openapitools/openapi-generator-cli" = "2.41.0"
|
||||
"npm:oazapfts" = "7.5.0"
|
||||
"github:extism/cli" = "1.6.3"
|
||||
|
||||
@@ -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 JPEG XL image of [length] bytes at [address], stopped by
|
||||
* [cancel], downscaled to cover [targetWidth] x [targetHeight] unless either is 0 and to fit
|
||||
* within [maxPixelSize].
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun decodeJxl(
|
||||
address: Long,
|
||||
length: Int,
|
||||
targetWidth: Int,
|
||||
targetHeight: Int,
|
||||
maxPixelSize: Int,
|
||||
cancelled: ByteBuffer,
|
||||
outInfo: IntArray
|
||||
): Long
|
||||
|
||||
/** Stops the [decodeJxl] 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
|
||||
@@ -56,10 +57,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 +97,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.decodeJxl(
|
||||
buffer.pointer,
|
||||
buffer.offset,
|
||||
width?.toInt() ?: 0,
|
||||
height?.toInt() ?: 0,
|
||||
MAX_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 +308,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 +368,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 +405,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 coreJxlDecode = symbol("native_core_jxl_decode", as: NativeCoreJxlDecodeFn.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 decodeJxl(_ data: Data, targetWidth: Int64?, targetHeight: Int64?, maxPixelSize: Int, cancelled: UnsafeMutablePointer<Bool>) -> (width: Int, height: Int, pointer: UnsafeMutableRawPointer)? {
|
||||
guard let coreJxlDecode else { return nil }
|
||||
var info = NativeCoreImageInfo()
|
||||
return data.withUnsafeBytes { bytes in
|
||||
guard let pointer = coreJxlDecode(bytes.bindMemory(to: UInt8.self).baseAddress, bytes.count, false, UInt32(clamping: targetWidth ?? 0), UInt32(clamping: targetHeight ?? 0), UInt32(clamping: maxPixelSize), cancelled, &info)
|
||||
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.decodeJxl(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_jxl_decode) NativeCoreJxlDecodeFn;
|
||||
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 {
|
||||
|
||||
@@ -75,6 +75,7 @@ abstract class AppConfig with _$AppConfig {
|
||||
.timelineTilesPerRow => timeline.tilesPerRow,
|
||||
.timelineGroupAssetsBy => timeline.groupAssetsBy,
|
||||
.timelineStorageIndicator => timeline.storageIndicator,
|
||||
.timelineSmoothScrubbing => timeline.smoothScrubbing,
|
||||
.mapShowFavoriteOnly => map.favoritesOnly,
|
||||
.mapRelativeDate => map.relativeDays,
|
||||
.mapCustomFrom => map.customFrom,
|
||||
@@ -131,6 +132,7 @@ abstract class AppConfig with _$AppConfig {
|
||||
.timelineTilesPerRow => copyWith(timeline: timeline.copyWith(tilesPerRow: value as int)),
|
||||
.timelineGroupAssetsBy => copyWith(timeline: timeline.copyWith(groupAssetsBy: value as GroupAssetsBy)),
|
||||
.timelineStorageIndicator => copyWith(timeline: timeline.copyWith(storageIndicator: value as bool)),
|
||||
.timelineSmoothScrubbing => copyWith(timeline: timeline.copyWith(smoothScrubbing: value as bool)),
|
||||
.mapShowFavoriteOnly => copyWith(map: map.copyWith(favoritesOnly: value as bool)),
|
||||
.mapRelativeDate => copyWith(map: map.copyWith(relativeDays: value as int)),
|
||||
.mapCustomFrom => copyWith(map: map.copyWith(customFrom: .fromNullable(value as DateTime?))),
|
||||
|
||||
@@ -10,5 +10,6 @@ abstract class TimelineConfig with _$TimelineConfig {
|
||||
@Default(4) int tilesPerRow,
|
||||
@Default(GroupAssetsBy.day) GroupAssetsBy groupAssetsBy,
|
||||
@Default(true) bool storageIndicator,
|
||||
@Default(false) bool smoothScrubbing,
|
||||
}) = _TimelineConfig;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ enum SettingsKey<T> {
|
||||
timelineTilesPerRow<int>(),
|
||||
timelineGroupAssetsBy<GroupAssetsBy>(codec: EnumCodec(GroupAssetsBy.values)),
|
||||
timelineStorageIndicator<bool>(),
|
||||
timelineSmoothScrubbing<bool>(),
|
||||
|
||||
// Log
|
||||
logLevel<LogLevel>(codec: EnumCodec(LogLevel.values)),
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:http/http.dart' show ClientException;
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/settings_key.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart' show ApiException;
|
||||
|
||||
/// Service responsible for handling application logging.
|
||||
///
|
||||
@@ -14,8 +18,15 @@ import 'package:logging/logging.dart';
|
||||
/// writes them to a persistent [LogRepository], and manages log levels via
|
||||
/// [SettingsRepository].
|
||||
class LogService {
|
||||
static final _schemeAndHost = RegExp(r'''https?://[^\s/?#'"<>(),]*''');
|
||||
static final _ipAddress = RegExp(
|
||||
r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b|'
|
||||
r'\[?\b(?:(?:[0-9a-f]{1,4}:){7}[0-9a-f]{1,4}|(?:[0-9a-f]{1,4}:)+(?::[0-9a-f]{1,4})+)(?:%\w+)?\]?',
|
||||
);
|
||||
|
||||
final LogRepository _logRepository;
|
||||
final SettingsRepository _settingsRepository;
|
||||
final StoreService _storeService;
|
||||
|
||||
final List<LogMessage> _msgBuffer = [];
|
||||
|
||||
@@ -39,11 +50,13 @@ class LogService {
|
||||
static Future<LogService> init({
|
||||
required LogRepository logRepository,
|
||||
required SettingsRepository settingsRepository,
|
||||
required StoreService storeService,
|
||||
bool shouldBuffer = true,
|
||||
}) async {
|
||||
_instance ??= await create(
|
||||
logRepository: logRepository,
|
||||
settingsRepository: settingsRepository,
|
||||
storeService: storeService,
|
||||
shouldBuffer: shouldBuffer,
|
||||
);
|
||||
return _instance!;
|
||||
@@ -52,16 +65,17 @@ class LogService {
|
||||
static Future<LogService> create({
|
||||
required LogRepository logRepository,
|
||||
required SettingsRepository settingsRepository,
|
||||
required StoreService storeService,
|
||||
bool shouldBuffer = true,
|
||||
}) async {
|
||||
final instance = LogService._(logRepository, settingsRepository, shouldBuffer);
|
||||
final instance = LogService._(logRepository, settingsRepository, storeService, shouldBuffer);
|
||||
await logRepository.truncate(limit: kLogTruncateLimit);
|
||||
final level = instance._settingsRepository.appConfig.logLevel;
|
||||
Logger.root.level = Level.LEVELS.elementAtOrNull(level.index) ?? Level.INFO;
|
||||
return instance;
|
||||
}
|
||||
|
||||
LogService._(this._logRepository, this._settingsRepository, this._shouldBuffer) {
|
||||
LogService._(this._logRepository, this._settingsRepository, this._storeService, this._shouldBuffer) {
|
||||
_logSubscription = Logger.root.onRecord.listen(_handleLogRecord);
|
||||
}
|
||||
|
||||
@@ -74,12 +88,12 @@ class LogService {
|
||||
);
|
||||
|
||||
final record = LogMessage(
|
||||
message: r.message,
|
||||
message: _redact(r.message),
|
||||
level: r.level.toLogLevel(),
|
||||
createdAt: r.time,
|
||||
logger: r.loggerName,
|
||||
error: r.error?.toString(),
|
||||
stack: r.stackTrace?.toString(),
|
||||
error: r.error == null ? null : _redactError(r.error!),
|
||||
stack: r.stackTrace == null ? null : _redact(r.stackTrace.toString()),
|
||||
);
|
||||
|
||||
if (_shouldBuffer) {
|
||||
@@ -100,6 +114,64 @@ class LogService {
|
||||
return [..._msgBuffer.reversed, ...logsFromDb];
|
||||
}
|
||||
|
||||
String _redact(String text) {
|
||||
var redacted = text.replaceAllMapped(_schemeAndHost, (match) => _urlLabel(Uri.tryParse(match[0]!)));
|
||||
final accessToken = _storeService.tryGet(StoreKey.accessToken);
|
||||
if (accessToken != null) {
|
||||
redacted = redacted.replaceAll(accessToken, '[ACCESS TOKEN]');
|
||||
}
|
||||
// as a header map prints it, so a short value like "1" can't match other text
|
||||
for (final header in _settingsRepository.appConfig.network.customHeaders.entries) {
|
||||
redacted = redacted.replaceAll(
|
||||
RegExp('${RegExp.escape(header.key)}:\\s+${RegExp.escape(header.value)}'),
|
||||
'${header.key}: [HEADER VALUE]',
|
||||
);
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
String _redactError(Object error) {
|
||||
final redacted = _redact(error.toString());
|
||||
final httpError = error is ApiException ? error.innerException : error;
|
||||
if (httpError is! ClientException) {
|
||||
return redacted;
|
||||
}
|
||||
|
||||
// ok_http also prints the host and its ip on their own: "Failed to connect to host/203.0.113.9:443"
|
||||
final url = httpError.uri;
|
||||
final withoutHost = url == null || url.host.isEmpty
|
||||
? redacted
|
||||
: redacted.replaceAll(RegExp('\\b${RegExp.escape(url.host)}\\b'), _urlLabel(url));
|
||||
return withoutHost.replaceAll(_ipAddress, '[IP ADDRESS]');
|
||||
}
|
||||
|
||||
String _urlLabel(Uri? url) {
|
||||
if (url == null) {
|
||||
return '[OTHER URL]';
|
||||
}
|
||||
|
||||
// a server url that is also a switching entry gets the entry's label
|
||||
final network = _settingsRepository.appConfig.network;
|
||||
for (var i = 0; i < network.externalEndpointList.length; i++) {
|
||||
if (_isSameAddress(url, network.externalEndpointList[i])) {
|
||||
return '[EXTERNAL ENDPOINT ${i + 1}]';
|
||||
}
|
||||
}
|
||||
if (_isSameAddress(url, network.localEndpoint)) {
|
||||
return '[LOCAL ENDPOINT]';
|
||||
}
|
||||
if (_isSameAddress(url, _storeService.tryGet(StoreKey.serverUrl)) ||
|
||||
_isSameAddress(url, _storeService.tryGet(StoreKey.serverEndpoint))) {
|
||||
return '[SERVER URL]';
|
||||
}
|
||||
return '[OTHER URL]';
|
||||
}
|
||||
|
||||
bool _isSameAddress(Uri url, String? savedUrl) {
|
||||
final saved = savedUrl == null ? null : Uri.tryParse(savedUrl);
|
||||
return saved != null && saved.scheme == url.scheme && saved.host == url.host && saved.port == url.port;
|
||||
}
|
||||
|
||||
Future<void> clearLogs() async {
|
||||
_flushTimer?.cancel();
|
||||
_flushTimer = null;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ void main() async {
|
||||
),
|
||||
);
|
||||
} catch (error, stack) {
|
||||
Logger('main').severe('Failed to start', error, stack);
|
||||
runApp(BootstrapErrorWidget(error: error.toString(), stack: stack.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/immich_logger.service.dart';
|
||||
import 'package:immich_mobile/theme/color_scheme.dart';
|
||||
import 'package:immich_mobile/theme/theme_data.dart';
|
||||
import 'package:immich_mobile/utils/fallback_localizations.dart';
|
||||
@@ -78,9 +79,12 @@ class BootstrapErrorWidget extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
const SafeArea(
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(padding: EdgeInsets.fromLTRB(24, 16, 24, 16), child: _BottomPanel()),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 16),
|
||||
child: _BottomPanel(startupError: '$error\n\n$stack'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -93,7 +97,9 @@ class BootstrapErrorWidget extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _BottomPanel extends StatefulWidget {
|
||||
const _BottomPanel();
|
||||
final String startupError;
|
||||
|
||||
const _BottomPanel({required this.startupError});
|
||||
|
||||
@override
|
||||
State<_BottomPanel> createState() => _BottomPanelState();
|
||||
@@ -157,8 +163,8 @@ class _BottomPanelState extends State<_BottomPanel> {
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
_ActionLink(
|
||||
icon: Icons.chat_bubble_outline,
|
||||
@@ -173,6 +179,11 @@ class _BottomPanelState extends State<_BottomPanel> {
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
),
|
||||
_ActionLink(
|
||||
icon: Icons.share_outlined,
|
||||
label: context.t.share_logs,
|
||||
onTap: () => ImmichLogger.shareLogs(context, startupError: widget.startupError),
|
||||
),
|
||||
if (!_cleared)
|
||||
_ActionLink(
|
||||
icon: Icons.delete_outline,
|
||||
@@ -288,21 +299,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(
|
||||
ref
|
||||
.read(authProvider.notifier)
|
||||
.setOpenApiServiceEndpoint()
|
||||
.then(logConnectionInfo)
|
||||
.whenComplete(() => resumeSession()),
|
||||
);
|
||||
}
|
||||
|
||||
void logConnectionInfo(String? endpoint) {
|
||||
if (endpoint == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Resuming session at $endpoint");
|
||||
unawaited(ref.read(authProvider.notifier).setOpenApiServiceEndpoint().whenComplete(() => resumeSession()));
|
||||
}
|
||||
|
||||
Future<void> resumeSession() async {
|
||||
@@ -356,7 +353,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
}
|
||||
},
|
||||
onError: (exception) {
|
||||
log.severe('Failed to update auth info with access token: $accessToken');
|
||||
log.severe('Failed to update auth info', exception);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -417,21 +417,57 @@ final sharedWithPartnerProvider = StreamProvider.autoDispose<Iterable<Partner>>(
|
||||
return ref.watch(partnerServiceProvider).search(currentUser.id, .sharedWith);
|
||||
});
|
||||
|
||||
class _QuickAccessItem {
|
||||
final Widget leading;
|
||||
final String title;
|
||||
final VoidCallback onTap;
|
||||
final EdgeInsetsGeometry? contentPadding;
|
||||
|
||||
const _QuickAccessItem({required this.leading, required this.title, required this.onTap, this.contentPadding});
|
||||
}
|
||||
|
||||
class _QuickAccessButtonList extends ConsumerWidget {
|
||||
const _QuickAccessButtonList();
|
||||
|
||||
static const _tileRadius = 20.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final partnerSharedWithAsync = ref.watch(sharedWithPartnerProvider);
|
||||
final partners = partnerSharedWithAsync.valueOrNull ?? [];
|
||||
|
||||
final items = [
|
||||
_QuickAccessItem(
|
||||
leading: const Icon(Icons.folder_outlined, size: 26),
|
||||
title: context.t.folders,
|
||||
onTap: () => context.pushRoute(FolderRoute()),
|
||||
),
|
||||
_QuickAccessItem(
|
||||
leading: const Icon(Icons.lock_outline_rounded, size: 26),
|
||||
title: context.t.locked_folder,
|
||||
onTap: () => context.pushRoute(const LockedFolderRoute()),
|
||||
),
|
||||
_QuickAccessItem(
|
||||
leading: const Icon(Icons.group_outlined, size: 26),
|
||||
title: context.t.partners,
|
||||
onTap: () => context.pushRoute(const PartnerRoute()),
|
||||
),
|
||||
for (final partner in partners)
|
||||
_QuickAccessItem(
|
||||
contentPadding: const EdgeInsets.only(left: 12.0, right: 18.0),
|
||||
leading: PartnerUserAvatar(userId: partner.id, name: partner.name),
|
||||
title: context.t.partner_list_user_photos(user: partner.name),
|
||||
onTap: () => context.pushRoute(PartnerDetailRoute(partner: partner)),
|
||||
),
|
||||
];
|
||||
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 12, right: 16, bottom: 32),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: context.colorScheme.onSurface.withAlpha(10), width: 1),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(_tileRadius)),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
context.colorScheme.primary.withAlpha(10),
|
||||
@@ -442,83 +478,33 @@ class _QuickAccessButtonList extends ConsumerWidget {
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
child: ListView(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: [
|
||||
ListTile(
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
final isFirst = index == 0;
|
||||
final isLast = index == items.length - 1;
|
||||
return ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(20),
|
||||
topRight: const Radius.circular(20),
|
||||
bottomLeft: Radius.circular(partners.isEmpty ? 20 : 0),
|
||||
bottomRight: Radius.circular(partners.isEmpty ? 20 : 0),
|
||||
topLeft: Radius.circular(isFirst ? _tileRadius : 0),
|
||||
topRight: Radius.circular(isFirst ? _tileRadius : 0),
|
||||
bottomLeft: Radius.circular(isLast ? _tileRadius : 0),
|
||||
bottomRight: Radius.circular(isLast ? _tileRadius : 0),
|
||||
),
|
||||
),
|
||||
leading: const Icon(Icons.folder_outlined, size: 26),
|
||||
title: Text(
|
||||
context.t.folders,
|
||||
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
onTap: () => context.pushRoute(FolderRoute()),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.lock_outline_rounded, size: 26),
|
||||
title: Text(
|
||||
context.t.locked_folder,
|
||||
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
onTap: () => context.pushRoute(const LockedFolderRoute()),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.group_outlined, size: 26),
|
||||
title: Text(
|
||||
context.t.partners,
|
||||
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
onTap: () => context.pushRoute(const PartnerRoute()),
|
||||
),
|
||||
_PartnerList(partners: partners.toList()),
|
||||
],
|
||||
contentPadding: item.contentPadding,
|
||||
leading: item.leading,
|
||||
title: Text(item.title, style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500)),
|
||||
onTap: item.onTap,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PartnerList extends StatelessWidget {
|
||||
const _PartnerList({required this.partners});
|
||||
|
||||
final List<Partner> partners;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: partners.length,
|
||||
shrinkWrap: true,
|
||||
itemBuilder: (context, index) {
|
||||
final partner = partners[index];
|
||||
final isLastItem = index == partners.length - 1;
|
||||
return ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(isLastItem ? 20 : 0),
|
||||
bottomRight: Radius.circular(isLastItem ? 20 : 0),
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.only(left: 12.0, right: 18.0),
|
||||
leading: PartnerUserAvatar(userId: partner.id, name: partner.name),
|
||||
title: Text(
|
||||
context.t.partner_list_user_photos(user: partner.name),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
onTap: () => context.pushRoute(PartnerDetailRoute(partner: partner)),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,6 +328,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
|
||||
final isMultiSelectStatusVisible = !isSelectionMode && isMultiSelectEnabled;
|
||||
final isBottomWidgetVisible =
|
||||
widget.bottomSheet != null && (isMultiSelectStatusVisible || widget.persistentBottomBar);
|
||||
final smoothScrubbing = ref.watch(appConfigProvider.select((config) => config.timeline.smoothScrubbing));
|
||||
final snapToMonth = widget.snapToMonth && !smoothScrubbing;
|
||||
|
||||
return PopScope(
|
||||
canPop: !isMultiSelectEnabled,
|
||||
@@ -409,7 +411,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
|
||||
final Widget timeline;
|
||||
if (widget.withScrubber) {
|
||||
timeline = Scrubber(
|
||||
snapToMonth: widget.snapToMonth,
|
||||
snapToMonth: snapToMonth,
|
||||
layoutSegments: segments,
|
||||
timelineHeight: maxHeight,
|
||||
topPadding: topPadding,
|
||||
|
||||
@@ -84,8 +84,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
// Needs to be logged in
|
||||
if (isAuthenticated) {
|
||||
// switch endpoint if needed
|
||||
final endpoint = await _ref.read(authProvider.notifier).setOpenApiServiceEndpoint();
|
||||
_log.info("Using server URL: $endpoint");
|
||||
await _ref.read(authProvider.notifier).setOpenApiServiceEndpoint();
|
||||
|
||||
await _ref.read(serverInfoProvider.notifier).getServerVersion();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/services/share_intent_service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
|
||||
@@ -21,7 +20,6 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
|
||||
final AppRouter router;
|
||||
final ForegroundUploadService _foregroundUploadService;
|
||||
final ShareIntentService _shareIntentService;
|
||||
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
|
||||
|
||||
ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]);
|
||||
|
||||
@@ -74,8 +72,7 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
|
||||
onSuccess: (fileId, _) {
|
||||
_updateStatus(fileId, UploadStatus.complete, progress: 1.0);
|
||||
},
|
||||
onError: (fileId, errorMessage) {
|
||||
_logger.warning("Upload failed for file: $fileId, error: $errorMessage");
|
||||
onError: (fileId, _) {
|
||||
_updateStatus(fileId, UploadStatus.failed);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -221,8 +221,6 @@ class BackupNotifier extends StateNotifier<BackupState> {
|
||||
}
|
||||
|
||||
void _handleForegroundBackupError(String localAssetId, String errorMessage) {
|
||||
_logger.severe("Upload failed for $localAssetId: $errorMessage");
|
||||
|
||||
final currentItem = state.uploadItems[localAssetId];
|
||||
if (currentItem != null) {
|
||||
state = state.copyWith(
|
||||
|
||||
@@ -88,7 +88,7 @@ class UploadRepository {
|
||||
} on RequestAbortedException {
|
||||
rethrow;
|
||||
} on ClientException catch (error) {
|
||||
logger.warning("Upload $logContext failed before a response, resending once: $error");
|
||||
logger.warning("Upload $logContext failed before a response, resending once", error);
|
||||
response = await client.send(buildRequest());
|
||||
}
|
||||
|
||||
@@ -124,8 +124,8 @@ class UploadRepository {
|
||||
logger.warning("Upload $logContext was cancelled");
|
||||
return UploadResult.cancelled();
|
||||
} catch (error, stackTrace) {
|
||||
logger.warning("Error uploading $logContext: $error: $stackTrace");
|
||||
return UploadResult.error(errorMessage: error.toString());
|
||||
logger.warning("Error uploading $logContext", error, stackTrace);
|
||||
return UploadResult(isSuccess: false, isCancelled: false, errorMessage: error.toString(), error: error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,7 @@ class UploadResult {
|
||||
final String? remoteAssetId;
|
||||
final String? errorMessage;
|
||||
final int? statusCode;
|
||||
final Object? error;
|
||||
|
||||
const UploadResult({
|
||||
required this.isSuccess,
|
||||
@@ -173,6 +174,7 @@ class UploadResult {
|
||||
this.remoteAssetId,
|
||||
this.errorMessage,
|
||||
this.statusCode,
|
||||
this.error,
|
||||
});
|
||||
|
||||
factory UploadResult.success({required String remoteAssetId}) {
|
||||
|
||||
@@ -162,6 +162,7 @@ class AuthService {
|
||||
final localEndpoint = _authRepository.getLocalEndpoint();
|
||||
if (localEndpoint != null) {
|
||||
await _apiService.resolveAndSetEndpoint(localEndpoint);
|
||||
_log.info("Using local endpoint");
|
||||
return localEndpoint;
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
@@ -181,9 +182,11 @@ class AuthService {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (final endpoint in endpointList) {
|
||||
for (var i = 0; i < endpointList.length; i++) {
|
||||
try {
|
||||
return await _apiService.resolveAndSetEndpoint(endpoint.url);
|
||||
final url = await _apiService.resolveAndSetEndpoint(endpointList[i].url);
|
||||
_log.info("Using external endpoint ${i + 1}");
|
||||
return url;
|
||||
} on ApiException catch (error) {
|
||||
_log.severe("Cannot resolve endpoint", error);
|
||||
continue;
|
||||
|
||||
@@ -182,6 +182,7 @@ class ForegroundUploadService {
|
||||
if (result.isSuccess) {
|
||||
onSuccess?.call(fileId, result.remoteAssetId!);
|
||||
} else if (!result.isCancelled && result.errorMessage != null) {
|
||||
_logger.warning("Error(${result.statusCode}) uploading $fileId", result.error ?? result.errorMessage);
|
||||
onError?.call(fileId, result.errorMessage!);
|
||||
}
|
||||
},
|
||||
@@ -395,7 +396,8 @@ class ForegroundUploadService {
|
||||
} else if (result.errorMessage != null) {
|
||||
_logger.severe(
|
||||
() =>
|
||||
"Error(${result.statusCode}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt} | ${result.errorMessage}",
|
||||
"Error(${result.statusCode}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt}",
|
||||
result.error ?? result.errorMessage,
|
||||
);
|
||||
|
||||
callbacks.onError?.call(asset.localId!, result.errorMessage!);
|
||||
|
||||
@@ -2,7 +2,11 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/data/db/logger/database.dart';
|
||||
import 'package:immich_mobile/data/db/main/database.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
@@ -15,15 +19,37 @@ import 'package:share_plus/share_plus.dart';
|
||||
/// Logs can be shared by calling the `shareLogs` method, which will open a share dialog
|
||||
/// and generate a csv file.
|
||||
abstract final class ImmichLogger {
|
||||
static Future<void> shareLogs(BuildContext context) async {
|
||||
// the startup error screen can show before the log service starts, and then the startup error was not logged
|
||||
static Future<List<LogMessage>> _read(String? startupError) async {
|
||||
try {
|
||||
return await LogService.I.getMessages();
|
||||
} on LoggerUnInitializedException {
|
||||
final connection = await openSqliteConnection(name: 'immich_logs');
|
||||
final logs = DriftLogger.sqlite(connection);
|
||||
try {
|
||||
return [
|
||||
// if the log service had started, main would have logged this already
|
||||
if (startupError != null)
|
||||
LogMessage(message: startupError, level: LogLevel.severe, createdAt: DateTime.now()),
|
||||
...await LogRepository(logs).getAll(),
|
||||
];
|
||||
} finally {
|
||||
await logs.close();
|
||||
await connection.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> shareLogs(BuildContext context, {String? startupError}) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final dateTime = DateTime.now().toIso8601String();
|
||||
final filePath = '${tempDir.path}/Immich_log_$dateTime.log';
|
||||
final safeTimestamp = dateTime.replaceAll(':', '-');
|
||||
final filePath = '${tempDir.path}/Immich_log_$safeTimestamp.log';
|
||||
final logFile = await File(filePath).create();
|
||||
final io = logFile.openWrite();
|
||||
try {
|
||||
// Write messages
|
||||
for (final m in await LogService.I.getMessages()) {
|
||||
for (final m in await _read(startupError)) {
|
||||
final created = m.createdAt;
|
||||
final level = m.level.name.padRight(8);
|
||||
final logger = (m.logger ?? "<UNKNOWN_LOGGER>").padRight(20);
|
||||
|
||||
@@ -26,7 +26,7 @@ class OAuthService {
|
||||
);
|
||||
|
||||
final authUrl = dto?.url;
|
||||
log.info('Received Authorization URL: $authUrl');
|
||||
log.info('Received Authorization URL');
|
||||
|
||||
return authUrl;
|
||||
}
|
||||
@@ -34,7 +34,7 @@ class OAuthService {
|
||||
Future<LoginResponseDto?> oAuthLogin(String oauthUrl, String state, String codeVerifier) async {
|
||||
String result = await FlutterWebAuth2.authenticate(url: oauthUrl, callbackUrlScheme: callbackUrlScheme);
|
||||
|
||||
log.info('Received OAuth callback: $result');
|
||||
log.info('Received OAuth callback');
|
||||
|
||||
if (result.startsWith('app.immich:/oauth-callback')) {
|
||||
result = result.replaceAll('app.immich:/oauth-callback', 'app.immich:///oauth-callback');
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/data/data_controller.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||
@@ -72,6 +73,7 @@ abstract final class Bootstrap {
|
||||
await LogService.init(
|
||||
logRepository: LogRepository(dataController.logDb),
|
||||
settingsRepository: settingsRepo,
|
||||
storeService: StoreService.I,
|
||||
shouldBuffer: shouldBufferLogs,
|
||||
);
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ final Map<String, Map<String, Object?>> openApiPatches = {
|
||||
'ffmpeg': {
|
||||
'realtime': {'enabled': false},
|
||||
},
|
||||
'passkey': {'enabled': false},
|
||||
},
|
||||
'ServerConfigDto': {
|
||||
'mapLightStyleUrl': 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
@@ -55,12 +56,16 @@ final Map<String, Map<String, Object?>> openApiPatches = {
|
||||
'PeopleResponse': {'updateStrategy': 'everyone'},
|
||||
'PersonResponseDto': {'otherPeople': const [], 'sharedBy': const [], 'sharedWith': const []},
|
||||
'ServerVersionResponseDto': {'featureLevel': 0},
|
||||
'WorkflowResponseDto': {'logging': false},
|
||||
'AdminConfigDto': {
|
||||
'passkey': {'enabled': false, 'domain': null, 'additionalDomains': const []},
|
||||
},
|
||||
'UserConfigDto': {
|
||||
'notifications': {
|
||||
'smtp': {'enabled': false},
|
||||
},
|
||||
'passkey': {'enabled': false},
|
||||
},
|
||||
'WorkflowResponseDto': {'logging': false},
|
||||
};
|
||||
|
||||
// ignore: unused-code
|
||||
|
||||
@@ -36,64 +36,63 @@ class _MesmerizingSliverAppBarState extends ConsumerState<MesmerizingSliverAppBa
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
|
||||
Widget hideWhenSelecting(Widget child) => IgnorePointer(
|
||||
ignoring: isMultiSelectEnabled,
|
||||
child: AnimatedOpacity(duration: Durations.medium1, opacity: isMultiSelectEnabled ? 0 : 1, child: child),
|
||||
);
|
||||
|
||||
return isMultiSelectEnabled
|
||||
? SliverToBoxAdapter(
|
||||
child: switch (_scrollProgress) {
|
||||
< 0.8 => const SizedBox(height: 120),
|
||||
_ => const SizedBox(height: 352),
|
||||
},
|
||||
)
|
||||
: SliverAppBar(
|
||||
expandedHeight: 300.0,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
snap: false,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(
|
||||
Platform.isIOS ? Icons.arrow_back_ios_new_rounded : Icons.arrow_back,
|
||||
color: Color.lerp(Colors.white, context.primaryColor, _scrollProgress),
|
||||
shadows: [
|
||||
_scrollProgress < 0.95
|
||||
? Shadow(offset: const Offset(0, 2), blurRadius: 5, color: Colors.black.withValues(alpha: 0.5))
|
||||
: const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
|
||||
],
|
||||
),
|
||||
onPressed: () {
|
||||
context.pop();
|
||||
},
|
||||
),
|
||||
flexibleSpace: Builder(
|
||||
builder: (context) {
|
||||
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
|
||||
final scrollProgress = _calculateScrollProgress(settings);
|
||||
return SliverAppBar(
|
||||
expandedHeight: 300.0,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
snap: false,
|
||||
elevation: 0,
|
||||
leading: hideWhenSelecting(
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Platform.isIOS ? Icons.arrow_back_ios_new_rounded : Icons.arrow_back,
|
||||
color: Color.lerp(Colors.white, context.primaryColor, _scrollProgress),
|
||||
shadows: [
|
||||
_scrollProgress < 0.95
|
||||
? Shadow(offset: const Offset(0, 2), blurRadius: 5, color: Colors.black.withValues(alpha: 0.5))
|
||||
: const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
|
||||
],
|
||||
),
|
||||
onPressed: () {
|
||||
context.pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
flexibleSpace: Builder(
|
||||
builder: (context) {
|
||||
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
|
||||
final scrollProgress = _calculateScrollProgress(settings);
|
||||
|
||||
// Update scroll progress for the leading button
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _scrollProgress != scrollProgress) {
|
||||
setState(() {
|
||||
_scrollProgress = scrollProgress;
|
||||
});
|
||||
}
|
||||
});
|
||||
// Update scroll progress for the leading button
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _scrollProgress != scrollProgress) {
|
||||
setState(() {
|
||||
_scrollProgress = scrollProgress;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
centerTitle: true,
|
||||
title: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: scrollProgress > 0.95
|
||||
? Text(
|
||||
widget.title,
|
||||
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
background: _ExpandedBackground(scrollProgress: scrollProgress, title: widget.title),
|
||||
);
|
||||
},
|
||||
return FlexibleSpaceBar(
|
||||
centerTitle: true,
|
||||
title: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: scrollProgress > 0.95
|
||||
? Text(
|
||||
widget.title,
|
||||
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
background: _ExpandedBackground(scrollProgress: scrollProgress, title: widget.title),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,10 @@ class _MesmerizingSliverAppBarState extends ConsumerState<PersonSliverAppBar> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
|
||||
Widget hideWhenSelecting(Widget child) => IgnorePointer(
|
||||
ignoring: isMultiSelectEnabled,
|
||||
child: AnimatedOpacity(duration: Durations.medium1, opacity: isMultiSelectEnabled ? 0 : 1, child: child),
|
||||
);
|
||||
final Color? actionIconColor = Color.lerp(Colors.white, context.primaryColor, _scrollProgress);
|
||||
final List<Shadow> actionIconShadows = [
|
||||
if (_scrollProgress < 0.95)
|
||||
@@ -62,74 +66,74 @@ class _MesmerizingSliverAppBarState extends ConsumerState<PersonSliverAppBar> {
|
||||
const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
|
||||
];
|
||||
|
||||
return isMultiSelectEnabled
|
||||
? SliverToBoxAdapter(
|
||||
child: switch (_scrollProgress) {
|
||||
< 0.8 => const SizedBox(height: 120),
|
||||
_ => const SizedBox(height: 352),
|
||||
},
|
||||
)
|
||||
: SliverAppBar(
|
||||
expandedHeight: 300.0,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
snap: false,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(
|
||||
Platform.isIOS ? Icons.arrow_back_ios_new_rounded : Icons.arrow_back,
|
||||
color: Color.lerp(Colors.white, context.primaryColor, _scrollProgress),
|
||||
shadows: [
|
||||
_scrollProgress < 0.95
|
||||
? Shadow(offset: const Offset(0, 2), blurRadius: 5, color: Colors.black.withValues(alpha: 0.5))
|
||||
: const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
|
||||
],
|
||||
),
|
||||
onPressed: () {
|
||||
context.pop();
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.more_vert, color: actionIconColor, shadows: actionIconShadows),
|
||||
onPressed: widget.onShowOptions,
|
||||
),
|
||||
return SliverAppBar(
|
||||
expandedHeight: 300.0,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
snap: false,
|
||||
elevation: 0,
|
||||
leading: hideWhenSelecting(
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Platform.isIOS ? Icons.arrow_back_ios_new_rounded : Icons.arrow_back,
|
||||
color: Color.lerp(Colors.white, context.primaryColor, _scrollProgress),
|
||||
shadows: [
|
||||
_scrollProgress < 0.95
|
||||
? Shadow(offset: const Offset(0, 2), blurRadius: 5, color: Colors.black.withValues(alpha: 0.5))
|
||||
: const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
|
||||
],
|
||||
flexibleSpace: Builder(
|
||||
builder: (context) {
|
||||
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
|
||||
final scrollProgress = _calculateScrollProgress(settings);
|
||||
),
|
||||
onPressed: () {
|
||||
context.pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
hideWhenSelecting(
|
||||
IconButton(
|
||||
icon: Icon(Icons.more_vert, color: actionIconColor, shadows: actionIconShadows),
|
||||
onPressed: widget.onShowOptions,
|
||||
),
|
||||
),
|
||||
],
|
||||
flexibleSpace: Builder(
|
||||
builder: (context) {
|
||||
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
|
||||
final scrollProgress = _calculateScrollProgress(settings);
|
||||
|
||||
// Update scroll progress for the leading button
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _scrollProgress != scrollProgress) {
|
||||
setState(() {
|
||||
_scrollProgress = scrollProgress;
|
||||
});
|
||||
}
|
||||
});
|
||||
// Update scroll progress for the leading button
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _scrollProgress != scrollProgress) {
|
||||
setState(() {
|
||||
_scrollProgress = scrollProgress;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
centerTitle: true,
|
||||
title: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: scrollProgress > 0.95
|
||||
? Text(
|
||||
widget.person.name,
|
||||
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
background: _ExpandedBackground(
|
||||
scrollProgress: scrollProgress,
|
||||
person: widget.person,
|
||||
onNameTap: widget.onNameTap,
|
||||
onBirthdayTap: widget.onBirthdayTap,
|
||||
),
|
||||
);
|
||||
},
|
||||
return FlexibleSpaceBar(
|
||||
centerTitle: true,
|
||||
title: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: scrollProgress > 0.95
|
||||
? Text(
|
||||
widget.person.name,
|
||||
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
background: IgnorePointer(
|
||||
ignoring: isMultiSelectEnabled,
|
||||
child: _ExpandedBackground(
|
||||
scrollProgress: scrollProgress,
|
||||
person: widget.person,
|
||||
onNameTap: widget.onNameTap,
|
||||
onBirthdayTap: widget.onBirthdayTap,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ class AssetListSettings extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final storageIndicator = useValueNotifier(ref.watch(appConfigProvider.select((s) => s.timeline.storageIndicator)));
|
||||
final smoothScrubbing = useValueNotifier(ref.watch(appConfigProvider.select((s) => s.timeline.smoothScrubbing)));
|
||||
|
||||
final assetListSetting = [
|
||||
SettingsSwitchListTile(
|
||||
@@ -27,6 +28,16 @@ class AssetListSettings extends HookConsumerWidget {
|
||||
ref.invalidate(settingsProvider);
|
||||
},
|
||||
),
|
||||
SettingsSwitchListTile(
|
||||
valueNotifier: smoothScrubbing,
|
||||
title: context.t.theme_setting_asset_list_smooth_scrubbing_title,
|
||||
subtitle: context.t.theme_setting_asset_list_smooth_scrubbing_subtitle,
|
||||
onChanged: (value) {
|
||||
unawaited(ref.read(settingsProvider).write(.timelineSmoothScrubbing, value));
|
||||
ref.invalidate(appSettingsServiceProvider);
|
||||
ref.invalidate(settingsProvider);
|
||||
},
|
||||
),
|
||||
const LayoutSettings(),
|
||||
const GroupSettings(),
|
||||
];
|
||||
|
||||
@@ -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"
|
||||
@@ -139,6 +146,7 @@ depends = ["//:open-api-dart"]
|
||||
run = [
|
||||
# This receives passed `--enforce-lockfile` args
|
||||
"flutter pub get",
|
||||
{ task = "install:build-runner-patch" },
|
||||
{ task = "install:ios" },
|
||||
]
|
||||
|
||||
@@ -198,6 +206,11 @@ fi
|
||||
cd ios && pod install
|
||||
'''
|
||||
|
||||
[tasks."install:build-runner-patch"]
|
||||
description = "Patch build_runner to prevent crashes when sources are deleted"
|
||||
hide = true
|
||||
run = "node ./scripts/build_runner_patch.mjs"
|
||||
|
||||
[tasks."analyze:dart"]
|
||||
description = "Run Dart analysis"
|
||||
hide = true
|
||||
|
||||
+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.3-local.6+3030404
|
||||
version: 3.3.5-local.9+3030502
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Prevents build_runner crashing when a source file is deleted from a path dependency, which commonly occurs to `generated/openapi` on branch switch
|
||||
//
|
||||
// Fixed upstream in 2.15.3 (https://github.com/dart-lang/build/pull/5022), but we can't take that dependency due to Dart analyzer being locked
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const packageConfig = new URL('../.dart_tool/package_config.json', import.meta.url);
|
||||
|
||||
const config = JSON.parse(readFileSync(packageConfig, 'utf8'));
|
||||
const buildRunner = new URL(config.packages.find(({ name }) => name === 'build_runner').rootUri);
|
||||
|
||||
const file = new URL(`${buildRunner}/lib/src/build_plan/build_plan.dart`);
|
||||
|
||||
const buggy = 'for (final id in deletedOutputs) {';
|
||||
const fixed = 'for (final id in deletedOutputs.difference(buildInputs.deletedSources.build().asSet())) {';
|
||||
|
||||
const source = readFileSync(file, 'utf8');
|
||||
if (source.includes(buggy)) {
|
||||
writeFileSync(file, source.replace(buggy, fixed));
|
||||
console.log('build_runner patch: stopped deleting removed sources from path dependencies');
|
||||
}
|
||||
@@ -1,13 +1,23 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' show ClientException;
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/data/db/main/database.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/network_config.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/settings_key.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:openapi/api.dart' show ApiException;
|
||||
|
||||
import '../../infrastructure/repository.mock.dart';
|
||||
import '../../test_utils.dart';
|
||||
@@ -30,6 +40,16 @@ void main() {
|
||||
late LogService sut;
|
||||
late LogRepository mockLogRepo;
|
||||
late MockSettingsRepository mockSettingsRepository;
|
||||
late Drift db;
|
||||
|
||||
setUpAll(() async {
|
||||
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
||||
await StoreService.init(storeRepository: StoreRepository(db), listenUpdates: false);
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
mockLogRepo = MockLogRepository();
|
||||
@@ -45,11 +65,16 @@ void main() {
|
||||
when(() => mockLogRepo.insert(any())).thenAnswer((_) async => true);
|
||||
when(() => mockLogRepo.insertAll(any())).thenAnswer((_) async => true);
|
||||
|
||||
sut = await LogService.create(logRepository: mockLogRepo, settingsRepository: mockSettingsRepository);
|
||||
sut = await LogService.create(
|
||||
logRepository: mockLogRepo,
|
||||
settingsRepository: mockSettingsRepository,
|
||||
storeService: StoreService.I,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await sut.dispose();
|
||||
await StoreService.I.clear();
|
||||
});
|
||||
|
||||
group("Log Service Init:", () {
|
||||
@@ -87,6 +112,7 @@ void main() {
|
||||
sut = await LogService.create(
|
||||
logRepository: mockLogRepo,
|
||||
settingsRepository: mockSettingsRepository,
|
||||
storeService: StoreService.I,
|
||||
shouldBuffer: true,
|
||||
);
|
||||
|
||||
@@ -105,6 +131,7 @@ void main() {
|
||||
sut = await LogService.create(
|
||||
logRepository: mockLogRepo,
|
||||
settingsRepository: mockSettingsRepository,
|
||||
storeService: StoreService.I,
|
||||
shouldBuffer: true,
|
||||
);
|
||||
|
||||
@@ -126,6 +153,7 @@ void main() {
|
||||
sut = await LogService.create(
|
||||
logRepository: mockLogRepo,
|
||||
settingsRepository: mockSettingsRepository,
|
||||
storeService: StoreService.I,
|
||||
shouldBuffer: false,
|
||||
);
|
||||
|
||||
@@ -160,6 +188,7 @@ void main() {
|
||||
sut = await LogService.create(
|
||||
logRepository: mockLogRepo,
|
||||
settingsRepository: mockSettingsRepository,
|
||||
storeService: StoreService.I,
|
||||
shouldBuffer: true,
|
||||
);
|
||||
|
||||
@@ -176,4 +205,144 @@ void main() {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group("Log Service Redaction:", () {
|
||||
const accessToken = 'Kq3Zr8WbN2xYp5aLm7cT9vQ4sD6fH1jE0gU2iO5wA8y';
|
||||
|
||||
test('Replaces urls, the access token and header values when a line is logged', () async {
|
||||
await StoreService.I.put(StoreKey.serverUrl, 'https://immich.example.com');
|
||||
await StoreService.I.put(StoreKey.serverEndpoint, 'https://api.example.com/api');
|
||||
await StoreService.I.put(StoreKey.accessToken, accessToken);
|
||||
when(() => mockSettingsRepository.appConfig).thenReturn(
|
||||
const AppConfig(
|
||||
network: NetworkConfig(
|
||||
localEndpoint: 'http://192.168.1.5:2283',
|
||||
externalEndpointList: ['https://immich.example.com', 'http://nas.lan:2283'],
|
||||
customHeaders: {'X-Proxy-Key': 'cf-secret-abcdef0123', 'X-Debug': '1'},
|
||||
),
|
||||
),
|
||||
);
|
||||
const rows = {
|
||||
'Resuming session at https://immich.example.com/api': 'Resuming session at [EXTERNAL ENDPOINT 1]/api',
|
||||
'GET http://nas.lan:2283/api/server/ping': 'GET [EXTERNAL ENDPOINT 2]/api/server/ping',
|
||||
'GET http://192.168.1.5:2283/api/server/ping': 'GET [LOCAL ENDPOINT]/api/server/ping',
|
||||
'Using server URL: https://api.example.com/api': 'Using server URL: [SERVER URL]/api',
|
||||
'Received Authorization URL: https://auth.example.org/authorize':
|
||||
'Received Authorization URL: [OTHER URL]/authorize',
|
||||
'Causing statement: INSERT INTO "store_entity", parameters: 11, $accessToken':
|
||||
'Causing statement: INSERT INTO "store_entity", parameters: 11, [ACCESS TOKEN]',
|
||||
'Could not enqueue task DownloadTask{headers: {X-Proxy-Key: cf-secret-abcdef0123, X-Debug: 1}, retries: 1}':
|
||||
'Could not enqueue task DownloadTask{headers: {X-Proxy-Key: [HEADER VALUE], X-Debug: [HEADER VALUE]}, retries: 1}',
|
||||
'X-Proxy-Key:\tcf-secret-abcdef0123': 'X-Proxy-Key: [HEADER VALUE]',
|
||||
'Uploaded 1 assets in 1.2s': 'Uploaded 1 assets in 1.2s',
|
||||
};
|
||||
for (final row in rows.keys) {
|
||||
Logger('Test').info(row);
|
||||
}
|
||||
await sut.flush();
|
||||
|
||||
final stored = verify(() => mockLogRepo.insertAll(captureAny())).captured.single as List<LogMessage>;
|
||||
expect(stored.map((m) => m.message), rows.values);
|
||||
});
|
||||
|
||||
test('Hides the host and ip in http client errors', () async {
|
||||
when(
|
||||
() => mockSettingsRepository.appConfig,
|
||||
).thenReturn(const AppConfig(network: NetworkConfig(externalEndpointList: ['http://immich:2283'])));
|
||||
final connectError = ClientException(
|
||||
'java.net.ConnectException: Failed to connect to immich/192.168.1.5:2283',
|
||||
Uri.parse('http://immich:2283/api/server/ping'),
|
||||
);
|
||||
final unknownHostError = ClientException(
|
||||
'java.net.UnknownHostException: Unable to resolve host "typo.example.org": No address associated with hostname',
|
||||
Uri.parse('https://alice:secret@typo.example.org/api/users/me'),
|
||||
);
|
||||
|
||||
Logger('Test').severe(
|
||||
'Error while checking server availability',
|
||||
ApiException.withInner(
|
||||
400,
|
||||
'HTTP connection failed: GET /server/ping',
|
||||
connectError,
|
||||
StackTrace.fromString('#0 ApiService._isEndpointAvailable (package:immich_mobile/services/api.service.dart)'),
|
||||
),
|
||||
);
|
||||
Logger('Test').severe('Error validating auxiliary endpoint', unknownHostError);
|
||||
await sut.flush();
|
||||
|
||||
final stored = verify(() => mockLogRepo.insertAll(captureAny())).captured.single as List<LogMessage>;
|
||||
expect(
|
||||
stored.first.error,
|
||||
'ApiException 400: HTTP connection failed: GET /server/ping (Inner exception: ClientException: '
|
||||
'java.net.ConnectException: Failed to connect to [EXTERNAL ENDPOINT 1]/[IP ADDRESS]:2283, '
|
||||
'uri=[EXTERNAL ENDPOINT 1]/api/server/ping)\n\n'
|
||||
'#0 ApiService._isEndpointAvailable (package:immich_mobile/services/api.service.dart)',
|
||||
);
|
||||
expect(
|
||||
stored.last.error,
|
||||
'ClientException: java.net.UnknownHostException: Unable to resolve host "[OTHER URL]": '
|
||||
'No address associated with hostname, uri=[OTHER URL]/api/users/me',
|
||||
);
|
||||
});
|
||||
|
||||
test('Hides ipv6 addresses in http client errors', () async {
|
||||
const rows = {
|
||||
'java.net.ConnectException: Failed to connect to immich.example.com/[2001:db8::5]:443':
|
||||
'java.net.ConnectException: Failed to connect to [OTHER URL]/[IP ADDRESS]:443',
|
||||
'java.net.ConnectException: Failed to connect to immich.example.com/2001:db8::5:443':
|
||||
'java.net.ConnectException: Failed to connect to [OTHER URL]/[IP ADDRESS]',
|
||||
'java.net.SocketTimeoutException: failed to connect to immich.example.com/2001:db8::5 (port 443) '
|
||||
'from /2001:db8:1234:5678:9abc:def0:1234:5678 (port 41234) after 30000ms':
|
||||
'java.net.SocketTimeoutException: failed to connect to [OTHER URL]/[IP ADDRESS] (port 443) '
|
||||
'from /[IP ADDRESS] (port 41234) after 30000ms',
|
||||
'java.net.ConnectException: Failed to connect to /[fe80::5%wlan0]:2283':
|
||||
'java.net.ConnectException: Failed to connect to /[IP ADDRESS]:2283',
|
||||
'at 12:34:56': 'at 12:34:56',
|
||||
'0:00:01.500000': '0:00:01.500000',
|
||||
'deadbeef': 'deadbeef',
|
||||
'#0 ApiClient.invokeAPI (package:openapi/api_client.dart:131:7)':
|
||||
'#0 ApiClient.invokeAPI (package:openapi/api_client.dart:131:7)',
|
||||
};
|
||||
for (final row in rows.keys) {
|
||||
Logger('Test').severe('Upload error', ClientException(row, Uri.parse('https://immich.example.com/api/assets')));
|
||||
}
|
||||
await sut.flush();
|
||||
|
||||
final stored = verify(() => mockLogRepo.insertAll(captureAny())).captured.single as List<LogMessage>;
|
||||
expect(stored.map((m) => m.error), [
|
||||
for (final row in rows.values) 'ClientException: $row, uri=[OTHER URL]/api/assets',
|
||||
]);
|
||||
});
|
||||
|
||||
test('Keeps the labels of a line logged before a logout', () async {
|
||||
final settings = await SettingsRepository.ensureInitialized(db);
|
||||
addTearDown(SettingsRepository.reset);
|
||||
await settings.write(SettingsKey.networkExternalEndpointList, ['https://immich.example.com']);
|
||||
await StoreService.I.put(StoreKey.accessToken, accessToken);
|
||||
await sut.dispose();
|
||||
sut = await LogService.create(
|
||||
logRepository: mockLogRepo,
|
||||
settingsRepository: settings,
|
||||
storeService: StoreService.I,
|
||||
);
|
||||
|
||||
Logger('Test').info('Resuming session at https://immich.example.com/api with $accessToken');
|
||||
// the store and settings changes of AuthService.clearLocalData
|
||||
await StoreService.I.delete(StoreKey.accessToken);
|
||||
await settings.clear(const [
|
||||
.networkAutoEndpointSwitching,
|
||||
.networkPreferredWifiName,
|
||||
.networkLocalEndpoint,
|
||||
.networkExternalEndpointList,
|
||||
]);
|
||||
Logger('Test').info('Resuming session at https://immich.example.com/api');
|
||||
await sut.flush();
|
||||
|
||||
final stored = verify(() => mockLogRepo.insertAll(captureAny())).captured.single as List<LogMessage>;
|
||||
expect(stored.map((m) => m.message), [
|
||||
'Resuming session at [EXTERNAL ENDPOINT 1]/api with [ACCESS TOKEN]',
|
||||
'Resuming session at [OTHER URL]/api',
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@ void main() {
|
||||
logService = await LogService.init(
|
||||
logRepository: logRepository,
|
||||
settingsRepository: settingsRepository,
|
||||
storeService: MockStoreService(),
|
||||
shouldBuffer: false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class _MockHttpClient extends Mock implements http.Client {}
|
||||
@@ -124,4 +125,21 @@ void main() {
|
||||
expect(result.errorMessage, 'boom');
|
||||
verify(() => client.send(any())).called(1);
|
||||
});
|
||||
|
||||
test('a transport failure is logged as the error, not in the message', () async {
|
||||
final error = http.ClientException('Failed to connect to /203.0.113.9:2283');
|
||||
stubSend((_) => throw error);
|
||||
final level = Logger.root.level;
|
||||
addTearDown(() => Logger.root.level = level);
|
||||
Logger.root.level = Level.ALL;
|
||||
final records = <LogRecord>[];
|
||||
final subscription = sut.logger.onRecord.listen(records.add);
|
||||
|
||||
final result = await upload();
|
||||
await subscription.cancel();
|
||||
|
||||
expect(records.map((r) => r.error), [error, error]);
|
||||
expect(records.map((r) => r.message), everyElement(isNot(contains('203.0.113.9'))));
|
||||
expect(result.error, error);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/data/db/logger/database.dart';
|
||||
import 'package:immich_mobile/data/db/main/database.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||
import 'package:immich_mobile/services/immich_logger.service.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Shares the startup error and the stored logs before the log service starts', (tester) async {
|
||||
final dir = Directory.systemTemp.createTempSync('immich-logs');
|
||||
addTearDown(() => dir.delete(recursive: true));
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.flutter.io/path_provider'),
|
||||
(_) async => dir.path,
|
||||
);
|
||||
late String shared;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('dev.fluttercommunity.plus/share'),
|
||||
(call) async {
|
||||
shared = File(((call.arguments as Map)['paths'] as List).single as String).readAsStringSync();
|
||||
return 'success';
|
||||
},
|
||||
);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
|
||||
await tester.runAsync(() async {
|
||||
final connection = await openSqliteConnection(name: 'immich_logs');
|
||||
final logs = DriftLogger.sqlite(connection);
|
||||
await LogRepository(logs).insertAll([
|
||||
LogMessage(
|
||||
message: 'Failed to connect to old.example.net/203.0.113.9:443, uri=https://old.example.net/api',
|
||||
level: LogLevel.warning,
|
||||
createdAt: DateTime(2026, 10, 15),
|
||||
),
|
||||
]);
|
||||
await logs.close();
|
||||
await connection.close();
|
||||
|
||||
await ImmichLogger.shareLogs(
|
||||
tester.element(find.byType(SizedBox)),
|
||||
startupError: 'SqliteException(2067): UNIQUE constraint failed, parameters: 14, someone@example.com',
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
shared.split('\n').first,
|
||||
endsWith('| SqliteException(2067): UNIQUE constraint failed, parameters: 14, someone@example.com |'),
|
||||
);
|
||||
expect(
|
||||
shared,
|
||||
contains('| Failed to connect to old.example.net/203.0.113.9:443, uri=https://old.example.net/api |'),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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
+659
@@ -0,0 +1,659 @@
|
||||
# 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 = "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.190"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||
|
||||
[[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 = [
|
||||
"jxl",
|
||||
"libc",
|
||||
"moxcms",
|
||||
"pic-scale",
|
||||
"quick_cache",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[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/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,13 @@
|
||||
# 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/native_core`: the shared logic
|
||||
- `crates/native_core_ffi`: the C ABI, 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,16 @@
|
||||
[package]
|
||||
name = "native_core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
jxl = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
moxcms = { workspace = true }
|
||||
pic-scale = { workspace = true }
|
||||
quick_cache = { workspace = true }
|
||||
rayon = { workspace = 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,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,235 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
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 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], target: 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)
|
||||
&& (target == 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) = color::to_srgb(&profile.try_as_icc()?)?;
|
||||
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` pixels and to fit
|
||||
/// within `max_pixel_size`.
|
||||
pub fn decode(
|
||||
mut self,
|
||||
target: 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, 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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
pub mod color;
|
||||
pub mod convert;
|
||||
pub mod image;
|
||||
pub mod jxl;
|
||||
mod resize;
|
||||
pub mod rotate;
|
||||
pub mod thumbhash;
|
||||
|
||||
pub fn version() -> &'static str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
@@ -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` and to fit within `max_pixel_size`, if that
|
||||
/// is smaller.
|
||||
pub(crate) fn cover(
|
||||
(w, h): (usize, usize),
|
||||
target: 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 {
|
||||
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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Orientation {
|
||||
Normal,
|
||||
FlipHorizontal,
|
||||
Rotate180,
|
||||
FlipVertical,
|
||||
Transpose,
|
||||
Rotate90,
|
||||
Transverse,
|
||||
Rotate270,
|
||||
}
|
||||
|
||||
impl From<i32> for Orientation {
|
||||
fn from(value: i32) -> Self {
|
||||
match value {
|
||||
2 => Self::FlipHorizontal,
|
||||
3 => Self::Rotate180,
|
||||
4 => Self::FlipVertical,
|
||||
5 => Self::Transpose,
|
||||
6 => Self::Rotate90,
|
||||
7 => Self::Transverse,
|
||||
8 => Self::Rotate270,
|
||||
_ => Self::Normal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Orientation {
|
||||
pub fn dimensions(self, w: usize, h: usize) -> (usize, usize) {
|
||||
match self {
|
||||
Self::Transpose | Self::Rotate90 | Self::Transverse | Self::Rotate270 => (h, w),
|
||||
_ => (w, h),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies `h` rows of `w` pixels, `stride` pixels apart in `src`, into `dst` with `orientation`
|
||||
/// applied. `dst` is dense: `orientation.dimensions(w, h)` pixels.
|
||||
pub fn rotate(
|
||||
src: &[u32],
|
||||
stride: usize,
|
||||
w: usize,
|
||||
h: usize,
|
||||
orientation: Orientation,
|
||||
dst: &mut [u32],
|
||||
) {
|
||||
assert!(w <= stride && h.saturating_mul(stride) <= src.len() && w * h <= dst.len());
|
||||
let (dw, _) = orientation.dimensions(w, h);
|
||||
let (w, h, dw) = (w as isize, h as isize, dw as isize);
|
||||
// A source pixel (x, y) lands at base + x * step_x + y * step_y in the dense dw-wide output,
|
||||
// the same layout as Bitmap.createBitmap(src, matrixForExifOrientation(orientation)).
|
||||
let (base, step_x, step_y) = match orientation {
|
||||
Orientation::FlipHorizontal => (w - 1, -1, dw),
|
||||
Orientation::Rotate180 => ((h - 1) * dw + w - 1, -1, -dw),
|
||||
Orientation::FlipVertical => ((h - 1) * dw, 1, -dw),
|
||||
Orientation::Transpose => (0, dw, 1),
|
||||
Orientation::Rotate90 => (h - 1, dw, -1),
|
||||
Orientation::Transverse => ((w - 1) * dw + h - 1, -dw, -1),
|
||||
Orientation::Rotate270 => ((w - 1) * dw, -dw, 1),
|
||||
Orientation::Normal => (0, 1, dw),
|
||||
};
|
||||
let (src, dst) = (src.as_ptr(), dst.as_mut_ptr());
|
||||
// 32x32 u32 tiles are 4KB, so the scattered writes of a 90/270 transpose stay in L1.
|
||||
for ty in (0..h).step_by(32) {
|
||||
for tx in (0..w).step_by(32) {
|
||||
for y in ty..(ty + 32).min(h) {
|
||||
// SAFETY: src holds h rows of stride pixels (asserted above).
|
||||
let row = unsafe { src.add(y as usize * stride) };
|
||||
let mut idx = base + y * step_y + tx * step_x;
|
||||
for x in tx..(tx + 32).min(w) {
|
||||
// SAFETY: the affine mapping stays within the w*h output and x stays within its row.
|
||||
unsafe { dst.add(idx as usize).write(row.add(x as usize).read()) };
|
||||
idx += step_x;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright (c) 2023 Evan Wallace
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
use std::f32::consts::PI;
|
||||
|
||||
pub fn decode(hash: &[u8], rgba: &mut [u8; 32 * 32 * 4]) -> Option<(u32, u32)> {
|
||||
// Read the constants
|
||||
let &[h0, h1, h2, h3, h4] = hash.first_chunk()?;
|
||||
let header24 = u32::from(h0) | u32::from(h1) << 8 | u32::from(h2) << 16;
|
||||
let header16 = u16::from_le_bytes([h3, h4]);
|
||||
let l_dc = (header24 & 63) as f32 / 63.0;
|
||||
let p_dc = ((header24 >> 6) & 63) as f32 / 31.5 - 1.0;
|
||||
let q_dc = ((header24 >> 12) & 63) as f32 / 31.5 - 1.0;
|
||||
let has_alpha = header24 >> 23 != 0;
|
||||
let is_landscape = header16 >> 15 != 0;
|
||||
let long = if has_alpha { 5 } else { 7 };
|
||||
let short = usize::from(header16 & 7);
|
||||
if short == 0 {
|
||||
return None;
|
||||
}
|
||||
let lx = (if is_landscape { long } else { short }).max(3);
|
||||
let ly = (if is_landscape { short } else { long }).max(3);
|
||||
let alpha = if has_alpha { *hash.get(5)? } else { 15 };
|
||||
let a_dc = (alpha & 15) as f32 / 15.0;
|
||||
// Read the varying factors (boost saturation by 1.25x to compensate for quantization)
|
||||
let ac_start = if has_alpha { 6 } else { 5 };
|
||||
let mut ac_index = 0;
|
||||
let mut channel = |nx, ny, scale| -> Option<[f32; 49]> {
|
||||
let mut ac = [0.0; 49];
|
||||
let mut j = 0;
|
||||
for cy in 0..ny {
|
||||
for _ in (usize::from(cy == 0)..nx).take_while(|cx| cx * ny < nx * (ny - cy)) {
|
||||
let value = (hash.get(ac_start + (ac_index >> 1))? >> ((ac_index & 1) * 4)) & 15;
|
||||
ac[j] = (value as f32 / 7.5 - 1.0) * scale;
|
||||
ac_index += 1;
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
Some(ac)
|
||||
};
|
||||
let l_ac = channel(lx, ly, ((header24 >> 18) & 31) as f32 / 31.0)?;
|
||||
let p_ac = channel(3, 3, ((header16 >> 3) & 63) as f32 / 63.0 * 1.25)?;
|
||||
let q_ac = channel(3, 3, ((header16 >> 9) & 63) as f32 / 63.0 * 1.25)?;
|
||||
let a_ac = if has_alpha {
|
||||
channel(5, 5, (alpha >> 4) as f32 / 15.0)?
|
||||
} else {
|
||||
[0.0; 49]
|
||||
};
|
||||
// Decode using the DCT into RGB
|
||||
let ratio = if is_landscape {
|
||||
long as f32 / short as f32
|
||||
} else {
|
||||
short as f32 / long as f32
|
||||
};
|
||||
let w = (if ratio > 1.0 { 32.0 } else { 32.0 * ratio }).round() as u32;
|
||||
let h = (if ratio > 1.0 { 32.0 / ratio } else { 32.0 }).round() as u32;
|
||||
let channels = [(&l_ac, lx, ly), (&p_ac, 3, 3), (&q_ac, 3, 3), (&a_ac, 5, 5)];
|
||||
let count = if has_alpha { 4 } else { 3 };
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
// Precompute the coefficients
|
||||
let fx: [f32; 7] =
|
||||
std::array::from_fn(|cx| (PI / w as f32 * (x as f32 + 0.5) * cx as f32).cos());
|
||||
let fy: [f32; 7] =
|
||||
std::array::from_fn(|cy| (PI / h as f32 * (y as f32 + 0.5) * cy as f32).cos());
|
||||
let mut lpqa = [l_dc, p_dc, q_dc, a_dc];
|
||||
for (sum, &(ac, nx, ny)) in lpqa.iter_mut().zip(&channels[..count]) {
|
||||
let mut j = 0;
|
||||
for (cy, fy) in fy.iter().enumerate().take(ny) {
|
||||
let fy2 = fy * 2.0;
|
||||
for cx in (usize::from(cy == 0)..nx).take_while(|cx| cx * ny < nx * (ny - cy)) {
|
||||
*sum += ac[j] * fx[cx] * fy2;
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Convert to RGB
|
||||
let [l, p, q, a] = lpqa;
|
||||
let b = l - 2.0 / 3.0 * p;
|
||||
let r = (3.0 * l - b + q) / 2.0;
|
||||
let g = r - q;
|
||||
let i = ((y * w + x) * 4) as usize;
|
||||
for (dst, value) in rgba[i..i + 4].iter_mut().zip([r, g, b, a]) {
|
||||
*dst = (255.0 * value.min(1.0)).round().max(0.0) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((w, h))
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
use native_core::convert::convert_1010102;
|
||||
|
||||
#[test]
|
||||
fn channels_and_alpha() {
|
||||
for (a, byte) in [(0, 0), (1, 85), (2, 170), (3, 255)] {
|
||||
let src: [u32; 3] = [
|
||||
(0x0ff << 10) | (0x3ff << 20) | (a << 30),
|
||||
0x0ff | (0x3ff << 10) | (a << 30),
|
||||
0x3ff | (0x0ff << 20) | (a << 30),
|
||||
];
|
||||
let mut dst = [0; 3];
|
||||
convert_1010102(&src, 3, 3, 1, &mut dst);
|
||||
assert_eq!(
|
||||
dst,
|
||||
[
|
||||
0xff4000 | (byte << 24),
|
||||
0x00ff40 | (byte << 24),
|
||||
0x4000ff | (byte << 24)
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padded_rows() {
|
||||
let src: [u32; 6] = [
|
||||
0xc00003ff, 0xc00ffc00, 0xdeadbeef, 0xfff00000, 0xffffffff, 0xdeadbeef,
|
||||
];
|
||||
let mut dst = [0; 4];
|
||||
convert_1010102(&src, 3, 2, 2, &mut dst);
|
||||
assert_eq!(dst, [0xff0000ff, 0xff00ff00, 0xffff0000, 0xffffffff]);
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user