diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 72c816cc93..239a448bf6 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -84,7 +84,7 @@ jobs: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ inputs.ref || github.sha }} persist-credentials: false @@ -103,7 +103,7 @@ jobs: - name: Restore Gradle Cache id: cache-gradle-restore - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: | ~/.gradle/caches @@ -153,14 +153,14 @@ jobs: fi - name: Publish Android Artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: release-apk-signed path: mobile/build/app/outputs/flutter-apk/*.apk - name: Save Gradle Cache id: cache-gradle-save - uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 if: github.ref == 'refs/heads/main' with: path: | @@ -182,7 +182,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 with: ref: ${{ inputs.ref || github.sha }} persist-credentials: false @@ -286,7 +286,7 @@ jobs: security delete-keychain build.keychain || true - name: Upload IPA artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: ios-release-ipa path: mobile/ios/Runner.ipa diff --git a/.github/workflows/cache-cleanup.yml b/.github/workflows/cache-cleanup.yml index a75770ec49..55f91e7989 100644 --- a/.github/workflows/cache-cleanup.yml +++ b/.github/workflows/cache-cleanup.yml @@ -25,7 +25,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Check out code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 9bdf1ecb89..db7ca0f57b 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -35,7 +35,7 @@ jobs: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -78,7 +78,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 7e1f52b4db..71b5968960 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -50,7 +50,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index 3e55a13869..91916e4ed2 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -60,7 +60,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -86,7 +86,7 @@ jobs: run: pnpm build - name: Upload build output - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: docs-build-output path: docs/build/ diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 3a0e918812..1933b9d572 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -125,13 +125,13 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0 + uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1 - name: Load parameters id: parameters diff --git a/.github/workflows/docs-destroy.yml b/.github/workflows/docs-destroy.yml index 643c35b1af..80cc17d32b 100644 --- a/.github/workflows/docs-destroy.yml +++ b/.github/workflows/docs-destroy.yml @@ -23,13 +23,13 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0 + uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1 - name: Destroy Docs Subdomain env: diff --git a/.github/workflows/fix-format.yml b/.github/workflows/fix-format.yml index f77ca48b41..11a9ef06e4 100644 --- a/.github/workflows/fix-format.yml +++ b/.github/workflows/fix-format.yml @@ -22,7 +22,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: 'Checkout' - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.ref }} token: ${{ steps.generate-token.outputs.token }} diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index dd6504bdd2..1a4c2b7945 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -56,7 +56,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: token: ${{ steps.generate-token.outputs.token }} persist-credentials: true @@ -136,13 +136,13 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: token: ${{ steps.generate-token.outputs.token }} persist-credentials: false - name: Download APK - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: release-apk-signed github-token: ${{ steps.generate-token.outputs.token }} diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 64feb4f2d4..3ee96c45b7 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -23,7 +23,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: token: ${{ steps.generate-token.outputs.token }} persist-credentials: true @@ -159,7 +159,7 @@ jobs: - name: Create PR id: create-pr - uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 with: token: ${{ steps.generate-token.outputs.token }} commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cb64cd37cf..30783f5e9b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,7 +58,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: token: ${{ steps.generate-token.outputs.token }} persist-credentials: false @@ -74,7 +74,7 @@ jobs: echo "version=$VERSION" >> $GITHUB_OUTPUT - name: Download APK - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: release-apk-signed github-token: ${{ steps.generate-token.outputs.token }} diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index 9c70922df1..2446b5ffcd 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -22,7 +22,7 @@ jobs: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index 2b72ceb40a..c0d53388c6 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -55,7 +55,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ad9fd95b88..2aed8c6da2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,7 +69,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -114,7 +114,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -161,7 +161,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -203,7 +203,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -247,7 +247,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -285,7 +285,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -333,7 +333,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -379,7 +379,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false submodules: 'recursive' @@ -418,7 +418,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false submodules: 'recursive' @@ -473,7 +473,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false submodules: 'recursive' @@ -505,7 +505,7 @@ jobs: run: npx playwright test if: ${{ !cancelled() }} - name: Archive test results - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 if: success() || failure() with: name: e2e-web-test-results-${{ matrix.runner }} @@ -534,7 +534,7 @@ jobs: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -566,7 +566,7 @@ jobs: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -607,7 +607,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -636,7 +636,7 @@ jobs: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -658,7 +658,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -720,7 +720,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} diff --git a/docs/docs/features/sharing.md b/docs/docs/features/sharing.md index c19b4f48e1..a884884bee 100644 --- a/docs/docs/features/sharing.md +++ b/docs/docs/features/sharing.md @@ -33,7 +33,7 @@ You can create a public link to share a group of photos or videos, or an album, The public shared link is generated with a random URL, which acts as as a secret to avoid the link being guessed by unwanted parties, for instance. ``` -https://immich.yourdomain.com/share/JUckRMxlgpo7F9BpyqGk_cZEwDzaU_U5LU5_oNZp1ETIBa9dpQ0b5ghNm_22QVJfn3k +https://my.immich.app/share/JUckRMxlgpo7F9BpyqGk_cZEwDzaU_U5LU5_oNZp1ETIBa9dpQ0b5ghNm_22QVJfn3k ``` ### Creating a public share link diff --git a/e2e-auth-server/Dockerfile b/e2e-auth-server/Dockerfile new file mode 100644 index 0000000000..aa7527c483 --- /dev/null +++ b/e2e-auth-server/Dockerfile @@ -0,0 +1,6 @@ +FROM node:24.1.0-alpine3.20@sha256:8fe019e0d57dbdce5f5c27c0b63d2775cf34b00e3755a7dea969802d7e0c2b25 +RUN corepack enable +ADD package.json *.ts ./ +RUN pnpm install +EXPOSE 2286 +CMD ["pnpm", "run", "start"] diff --git a/e2e/src/setup/auth-server.ts b/e2e-auth-server/auth-server.ts similarity index 96% rename from e2e/src/setup/auth-server.ts rename to e2e-auth-server/auth-server.ts index 489bda2ee4..a190ecd023 100644 --- a/e2e/src/setup/auth-server.ts +++ b/e2e-auth-server/auth-server.ts @@ -125,7 +125,7 @@ const setup = async () => { ], }); - const onStart = () => console.log(`[auth-server] http://${host}:${port}/.well-known/openid-configuration`); + const onStart = () => console.log(`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`); const app = oidc.listen(port, host, onStart); return () => app.close(); }; diff --git a/e2e-auth-server/package.json b/e2e-auth-server/package.json new file mode 100644 index 0000000000..73ede1b7c4 --- /dev/null +++ b/e2e-auth-server/package.json @@ -0,0 +1,15 @@ +{ + "name": "@immich/e2e-auth-server", + "version": "0.1.0", + "type": "module", + "main": "auth-server.ts", + "scripts": { + "start": "tsx startup.ts" + }, + "devDependencies": { + "jose": "^5.6.3", + "@types/oidc-provider": "^9.0.0", + "oidc-provider": "^9.0.0", + "tsx": "^4.20.6" + } +} diff --git a/e2e-auth-server/startup.ts b/e2e-auth-server/startup.ts new file mode 100644 index 0000000000..442cf6dfc2 --- /dev/null +++ b/e2e-auth-server/startup.ts @@ -0,0 +1,8 @@ +import setup from './auth-server' + +const teardown = await setup() +process.on('exit', () => { + teardown() + console.log('[e2e-auth-server] stopped') + process.exit(0) +}) diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 867a367d54..a33cb6573c 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -1,6 +1,12 @@ name: immich-e2e services: + e2e-auth-server: + build: + context: ../e2e-auth-server + ports: + - 2286:2286 + immich-server: container_name: immich-e2e-server image: immich-server:latest @@ -27,8 +33,6 @@ services: - IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true volumes: - ./test-assets:/test-assets - extra_hosts: - - 'auth-server:host-gateway' depends_on: redis: condition: service_started diff --git a/e2e/package.json b/e2e/package.json index 909556aa99..c42bf6eddb 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -22,12 +22,12 @@ "@eslint/js": "^9.8.0", "@faker-js/faker": "^10.1.0", "@immich/cli": "file:../cli", + "@immich/e2e-auth-server": "file:../e2e-auth-server", "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@socket.io/component-emitter": "^3.1.2", "@types/luxon": "^3.4.2", "@types/node": "^24.10.4", - "@types/oidc-provider": "^9.0.0", "@types/pg": "^8.15.1", "@types/pngjs": "^6.0.4", "@types/supertest": "^6.0.2", @@ -38,9 +38,7 @@ "eslint-plugin-unicorn": "^62.0.0", "exiftool-vendored": "^34.3.0", "globals": "^16.0.0", - "jose": "^5.6.3", "luxon": "^3.4.4", - "oidc-provider": "^9.0.0", "pg": "^8.11.3", "pngjs": "^7.0.0", "prettier": "^3.7.4", diff --git a/e2e/src/api/specs/oauth.e2e-spec.ts b/e2e/src/api/specs/oauth.e2e-spec.ts index 58fc43a2d5..cbd68c003a 100644 --- a/e2e/src/api/specs/oauth.e2e-spec.ts +++ b/e2e/src/api/specs/oauth.e2e-spec.ts @@ -1,3 +1,4 @@ +import { OAuthClient, OAuthUser } from '@immich/e2e-auth-server'; import { LoginResponseDto, SystemConfigOAuthDto, @@ -8,13 +9,12 @@ import { } from '@immich/sdk'; import { createHash, randomBytes } from 'node:crypto'; import { errorDto } from 'src/responses'; -import { OAuthClient, OAuthUser } from 'src/setup/auth-server'; import { app, asBearerAuth, baseUrl, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; const authServer = { - internal: 'http://auth-server:2286', + internal: 'http://e2e-auth-server:2286', external: 'http://127.0.0.1:2286', }; diff --git a/e2e/src/generators/timeline/rest-response.ts b/e2e/src/generators/timeline/rest-response.ts index 6fcfe52fc2..21cf59e793 100644 --- a/e2e/src/generators/timeline/rest-response.ts +++ b/e2e/src/generators/timeline/rest-response.ts @@ -346,6 +346,8 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons duplicateId: null, resized: true, checksum: asset.checksum, + width: exifInfo.exifImageWidth ?? 1, + height: exifInfo.exifImageHeight ?? 1, }; } diff --git a/e2e/src/mock-network/timeline-network.ts b/e2e/src/mock-network/timeline-network.ts index 59bce71dd8..8780409657 100644 --- a/e2e/src/mock-network/timeline-network.ts +++ b/e2e/src/mock-network/timeline-network.ts @@ -1,3 +1,4 @@ +import { AssetResponseDto } from '@immich/sdk'; import { BrowserContext, Page, Request, Route } from '@playwright/test'; import { basename } from 'node:path'; import { @@ -63,15 +64,33 @@ export const setupTimelineMockApiRoutes = async ( }); await context.route('**/api/assets/*', async (route, request) => { - const url = new URL(request.url()); - const pathname = url.pathname; - const assetId = basename(pathname); - const asset = getAsset(timelineRestData, assetId); - return route.fulfill({ - status: 200, - contentType: 'application/json', - json: asset, - }); + if (request.method() === 'GET') { + const url = new URL(request.url()); + const pathname = url.pathname; + const assetId = basename(pathname); + let asset = getAsset(timelineRestData, assetId); + if (changes.assetDeletions.includes(asset!.id)) { + asset = { + ...asset, + isTrashed: true, + } as AssetResponseDto; + } + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: asset, + }); + } + await route.fallback(); + }); + + await context.route('**/api/assets', async (route, request) => { + if (request.method() === 'DELETE') { + return route.fulfill({ + status: 204, + }); + } + await route.fallback(); }); await context.route('**/api/assets/*/ocr', async (route) => { @@ -117,17 +136,28 @@ export const setupTimelineMockApiRoutes = async ( }); await context.route('**/api/albums/**', async (route, request) => { - const pattern = /\/api\/albums\/(?[^/?]+)/; - const match = request.url().match(pattern); - if (!match) { - return route.continue(); + const albumsMatch = request.url().match(/\/api\/albums\/(?[^/?]+)/); + if (albumsMatch) { + const album = getAlbum(timelineRestData, testContext.adminId, albumsMatch.groups?.albumId, changes); + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: album, + }); } - const album = getAlbum(timelineRestData, testContext.adminId, match.groups?.albumId, changes); - return route.fulfill({ - status: 200, - contentType: 'application/json', - json: album, - }); + return route.fallback(); + }); + + await context.route('**/api/albums**', async (route, request) => { + const allAlbums = request.url().match(/\/api\/albums\?assetId=(?[^&]+)/); + if (allAlbums) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: [], + }); + } + return route.fallback(); }); }; diff --git a/e2e/src/web/specs/asset-viewer/asset-viewer.parallel-e2e-spec.ts b/e2e/src/web/specs/asset-viewer/asset-viewer.parallel-e2e-spec.ts new file mode 100644 index 0000000000..eaf9d0d073 --- /dev/null +++ b/e2e/src/web/specs/asset-viewer/asset-viewer.parallel-e2e-spec.ts @@ -0,0 +1,156 @@ +import { faker } from '@faker-js/faker'; +import { test } from '@playwright/test'; +import { + Changes, + createDefaultTimelineConfig, + generateTimelineData, + SeededRandom, + selectRandom, + TimelineAssetConfig, + TimelineData, +} from 'src/generators/timeline'; +import { setupBaseMockApiRoutes } from 'src/mock-network/base-network'; +import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network'; +import { utils } from 'src/utils'; +import { assetViewerUtils, cancelAllPollers } from 'src/web/specs/timeline/utils'; + +test.describe.configure({ mode: 'parallel' }); +test.describe('asset-viewer', () => { + const rng = new SeededRandom(529); + let adminUserId: string; + let timelineRestData: TimelineData; + const assets: TimelineAssetConfig[] = []; + const yearMonths: string[] = []; + const testContext = new TimelineTestContext(); + const changes: Changes = { + albumAdditions: [], + assetDeletions: [], + assetArchivals: [], + assetFavorites: [], + }; + + test.beforeAll(async () => { + utils.initSdk(); + adminUserId = faker.string.uuid(); + testContext.adminId = adminUserId; + timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId }); + for (const timeBucket of timelineRestData.buckets.values()) { + assets.push(...timeBucket); + } + for (const yearMonth of timelineRestData.buckets.keys()) { + const [year, month] = yearMonth.split('-'); + yearMonths.push(`${year}-${Number(month)}`); + } + }); + + test.beforeEach(async ({ context }) => { + await setupBaseMockApiRoutes(context, adminUserId); + await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext); + }); + + test.afterEach(() => { + cancelAllPollers(); + testContext.slowBucket = false; + changes.albumAdditions = []; + changes.assetDeletions = []; + changes.assetArchivals = []; + changes.assetFavorites = []; + }); + + test.describe('/photos/:id', () => { + test('Delete photo advances to next', async ({ page }) => { + const asset = selectRandom(assets, rng); + await page.goto(`/photos/${asset.id}`); + await assetViewerUtils.waitForViewerLoad(page, asset); + await page.getByLabel('Delete').click(); + const index = assets.indexOf(asset); + await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]); + }); + test('Delete photo advances to next (2x)', async ({ page }) => { + const asset = selectRandom(assets, rng); + await page.goto(`/photos/${asset.id}`); + await assetViewerUtils.waitForViewerLoad(page, asset); + await page.getByLabel('Delete').click(); + const index = assets.indexOf(asset); + await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]); + await page.getByLabel('Delete').click(); + await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]); + }); + test('Delete last photo advances to prev', async ({ page }) => { + const asset = assets.at(-1)!; + await page.goto(`/photos/${asset.id}`); + await assetViewerUtils.waitForViewerLoad(page, asset); + await page.getByLabel('Delete').click(); + const index = assets.indexOf(asset); + await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]); + }); + test('Delete last photo advances to prev (2x)', async ({ page }) => { + const asset = assets.at(-1)!; + await page.goto(`/photos/${asset.id}`); + await assetViewerUtils.waitForViewerLoad(page, asset); + await page.getByLabel('Delete').click(); + const index = assets.indexOf(asset); + await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]); + await page.getByLabel('Delete').click(); + await assetViewerUtils.waitForViewerLoad(page, assets[index - 2]); + }); + }); + test.describe('/trash/photos/:id', () => { + test('Delete trashed photo advances to next', async ({ page }) => { + const asset = selectRandom(assets, rng); + const index = assets.indexOf(asset); + const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id); + changes.assetDeletions.push(...deletedAssets); + await page.goto(`/trash/photos/${asset.id}`); + await assetViewerUtils.waitForViewerLoad(page, asset); + await page.getByLabel('Delete').click(); + // confirm dialog + await page.getByRole('button').getByText('Delete').click(); + await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]); + }); + test('Delete trashed photo advances to next 2x', async ({ page }) => { + const asset = selectRandom(assets, rng); + const index = assets.indexOf(asset); + const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id); + changes.assetDeletions.push(...deletedAssets); + await page.goto(`/trash/photos/${asset.id}`); + await assetViewerUtils.waitForViewerLoad(page, asset); + await page.getByLabel('Delete').click(); + // confirm dialog + await page.getByRole('button').getByText('Delete').click(); + await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]); + await page.getByLabel('Delete').click(); + // confirm dialog + await page.getByRole('button').getByText('Delete').click(); + await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]); + }); + test('Delete trashed photo advances to prev', async ({ page }) => { + const asset = selectRandom(assets, rng); + const index = assets.indexOf(asset); + const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id); + changes.assetDeletions.push(...deletedAssets); + await page.goto(`/trash/photos/${assets[index + 9].id}`); + await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]); + await page.getByLabel('Delete').click(); + // confirm dialog + await page.getByRole('button').getByText('Delete').click(); + await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]); + }); + test('Delete trashed photo advances to prev 2x', async ({ page }) => { + const asset = selectRandom(assets, rng); + const index = assets.indexOf(asset); + const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id); + changes.assetDeletions.push(...deletedAssets); + await page.goto(`/trash/photos/${assets[index + 9].id}`); + await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]); + await page.getByLabel('Delete').click(); + // confirm dialog + await page.getByRole('button').getByText('Delete').click(); + await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]); + await page.getByLabel('Delete').click(); + // confirm dialog + await page.getByRole('button').getByText('Delete').click(); + await assetViewerUtils.waitForViewerLoad(page, assets[index + 7]); + }); + }); +}); diff --git a/e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts b/e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts index 6314688abb..5faf8380d1 100644 --- a/e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts +++ b/e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts @@ -463,7 +463,7 @@ test.describe('Timeline', () => { }); changes.albumAdditions.push(...requestJson.ids); }); - await page.getByText('Done').click(); + await page.getByText('Add assets').click(); await expect(put).resolves.toEqual({ ids: [ 'c077ea7b-cfa1-45e4-8554-f86c00ee5658', diff --git a/e2e/src/web/specs/timeline/utils.ts b/e2e/src/web/specs/timeline/utils.ts index 0b49f02941..397a1656e8 100644 --- a/e2e/src/web/specs/timeline/utils.ts +++ b/e2e/src/web/specs/timeline/utils.ts @@ -181,8 +181,12 @@ export const assetViewerUtils = { }, async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) { await page - .locator(`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}"]`) - .or(page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}"]`)) + .locator( + `img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`, + ) + .or( + page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`), + ) .waitFor(); }, async expectActiveAssetToBe(page: Page, assetId: string) { diff --git a/e2e/src/web/specs/user-admin.e2e-spec.ts b/e2e/src/web/specs/user-admin.e2e-spec.ts index 7a2cd77177..67a537ba9d 100644 --- a/e2e/src/web/specs/user-admin.e2e-spec.ts +++ b/e2e/src/web/specs/user-admin.e2e-spec.ts @@ -56,7 +56,7 @@ test.describe('User Administration', () => { await expect(page.getByLabel('Admin User')).not.toBeChecked(); await page.getByLabel('Admin User').click(); await expect(page.getByLabel('Admin User')).toBeChecked(); - await page.getByRole('button', { name: 'Confirm' }).click(); + await page.getByRole('button', { name: 'Save' }).click(); await expect .poll(async () => { @@ -85,7 +85,7 @@ test.describe('User Administration', () => { await expect(page.getByLabel('Admin User')).toBeChecked(); await page.getByLabel('Admin User').click(); await expect(page.getByLabel('Admin User')).not.toBeChecked(); - await page.getByRole('button', { name: 'Confirm' }).click(); + await page.getByRole('button', { name: 'Save' }).click(); await expect .poll(async () => { diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts index 9c80f25ace..48433eb830 100644 --- a/e2e/vitest.config.ts +++ b/e2e/vitest.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'vitest/config'; // skip `docker compose up` if `make e2e` was already run -const globalSetup: string[] = ['src/setup/auth-server.ts']; +const globalSetup: string[] = []; try { await fetch('http://127.0.0.1:2285/api/server-info/ping'); } catch { diff --git a/i18n/en.json b/i18n/en.json index 792cb442f8..2bb832ce5f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -18,6 +18,7 @@ "add_a_title": "Add a title", "add_action": "Add action", "add_action_description": "Click to add an action to perform", + "add_assets": "Add assets", "add_birthday": "Add a birthday", "add_endpoint": "Add endpoint", "add_exclusion_pattern": "Add exclusion pattern", @@ -487,6 +488,7 @@ "album_summary": "Album summary", "album_updated": "Album updated", "album_updated_setting_description": "Receive an email notification when a shared album has new assets", + "album_upload_assets": "Upload assets from your computer and add to album", "album_user_left": "Left {album}", "album_user_removed": "Removed {user}", "album_viewer_appbar_delete_confirm": "Are you sure you want to delete this album from your account?", @@ -840,6 +842,9 @@ "created_at": "Created", "creating_linked_albums": "Creating linked albums...", "crop": "Crop", + "crop_aspect_ratio_fixed": "Fixed", + "crop_aspect_ratio_free": "Free", + "crop_aspect_ratio_original": "Original", "curated_object_page_title": "Things", "current_device": "Current device", "current_pin_code": "Current PIN code", @@ -973,9 +978,13 @@ "editor": "Editor", "editor_close_without_save_prompt": "The changes will not be saved", "editor_close_without_save_title": "Close editor?", - "editor_crop_tool_h2_aspect_ratios": "Aspect ratios", - "editor_crop_tool_h2_rotation": "Rotation", - "editor_mode": "Editor mode", + "editor_confirm_reset_all_changes": "Are you sure you want to reset all changes?", + "editor_flip_horizontal": "Flip horizontal", + "editor_flip_vertical": "Flip vertical", + "editor_orientation": "Orientation", + "editor_reset_all_changes": "Reset changes", + "editor_rotate_left": "Rotate 90° counterclockwise", + "editor_rotate_right": "Rotate 90° clockwise", "email": "Email", "email_notifications": "Email notifications", "empty_folder": "This folder is empty", @@ -1482,6 +1491,8 @@ "minimize": "Minimize", "minute": "Minute", "minutes": "Minutes", + "mirror_horizontal": "Horizontal", + "mirror_vertical": "Vertical", "missing": "Missing", "mobile_app": "Mobile App", "mobile_app_download_onboarding_note": "Download the companion mobile app using the following options", diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index eb78ea0c8e..198733b3c8 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -4,7 +4,6 @@ import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; -import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; typedef _AssetVideoDimension = ({double? width, double? height, bool isFlipped}); @@ -99,9 +98,7 @@ class AssetService { height = fetched?.height?.toDouble(); } - final exif = await getExif(asset); - final isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation); - return (width: width, height: height, isFlipped: isFlipped); + return (width: width, height: height, isFlipped: false); } Future> getPlaces(String userId) { diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index 5ab1844571..b6dc7a2868 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -22,6 +22,7 @@ import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey; import 'package:openapi/api.dart' hide AssetVisibility, AlbumUserRole, UserMetadataKey; @@ -194,6 +195,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { livePhotoVideoId: Value(asset.livePhotoVideoId), stackId: Value(asset.stackId), libraryId: Value(asset.libraryId), + width: Value(asset.width), + height: Value(asset.height), ); batch.insert( @@ -245,10 +248,21 @@ class SyncStreamRepository extends DriftDatabaseRepository { await _db.batch((batch) { for (final exif in data) { + int? width; + int? height; + + if (ExifDtoConverter.isOrientationFlipped(exif.orientation)) { + width = exif.exifImageHeight; + height = exif.exifImageWidth; + } else { + width = exif.exifImageWidth; + height = exif.exifImageHeight; + } + batch.update( _db.remoteAssetEntity, - RemoteAssetEntityCompanion(width: Value(exif.exifImageWidth), height: Value(exif.exifImageHeight)), - where: (row) => row.id.equals(exif.assetId), + RemoteAssetEntityCompanion(width: Value(width), height: Value(height)), + where: (row) => row.id.equals(exif.assetId) & row.width.isNull() & row.height.isNull(), ); } }); diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index c3804d97f6..83bc840df1 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -42,6 +42,7 @@ import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/utils/licenses.dart'; import 'package:immich_mobile/utils/migration.dart'; import 'package:immich_mobile/wm_executor.dart'; +import 'package:immich_ui/immich_ui.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:logging/logging.dart'; import 'package:timezone/data/latest.dart'; @@ -252,6 +253,13 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve themeMode: ref.watch(immichThemeModeProvider), darkTheme: getThemeData(colorScheme: immichTheme.dark, locale: context.locale), theme: getThemeData(colorScheme: immichTheme.light, locale: context.locale), + builder: (context, child) => ImmichTranslationProvider( + translations: ImmichTranslations( + submit: "submit".t(context: context), + password: "password".t(context: context), + ), + child: ImmichThemeProvider(colorScheme: context.colorScheme, child: child!), + ), routerConfig: router.config( deepLinkBuilder: _deepLinkBuilder, navigatorObservers: () => [AppNavigationObserver(ref: ref)], diff --git a/mobile/lib/pages/search/map/map.page.dart b/mobile/lib/pages/search/map/map.page.dart index a93b826f03..e366cf70f1 100644 --- a/mobile/lib/pages/search/map/map.page.dart +++ b/mobile/lib/pages/search/map/map.page.dart @@ -370,6 +370,7 @@ class _MapWithMarker extends StatelessWidget { ? PositionedAssetMarkerIcon( point: value.point, assetRemoteId: value.marker.assetRemoteId, + assetThumbhash: '', durationInMilliseconds: value.shouldAnimate ? 100 : 0, onTap: onMarkerTapped, ) diff --git a/mobile/lib/presentation/pages/dev/ui_showcase.page.dart b/mobile/lib/presentation/pages/dev/ui_showcase.page.dart index 01fe928478..37c412a0e9 100644 --- a/mobile/lib/presentation/pages/dev/ui_showcase.page.dart +++ b/mobile/lib/presentation/pages/dev/ui_showcase.page.dart @@ -19,6 +19,17 @@ List _showcaseBuilder(Function(ImmichVariant variant, ImmichColor color) return children; } +class _ComponentTitle extends StatelessWidget { + final String title; + + const _ComponentTitle(this.title); + + @override + Widget build(BuildContext context) { + return Text(title, style: context.textTheme.titleLarge); + } +} + @RoutePage() class ImmichUIShowcasePage extends StatelessWidget { const ImmichUIShowcasePage({super.key}); @@ -35,13 +46,51 @@ class ImmichUIShowcasePage extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text("IconButton", style: context.textTheme.titleLarge), + const _ComponentTitle("IconButton"), ..._showcaseBuilder( (variant, color) => - ImmichIconButton(icon: Icons.favorite, color: color, variant: variant, onTap: () {}), + ImmichIconButton(icon: Icons.favorite, color: color, variant: variant, onPressed: () {}), + ), + const _ComponentTitle("CloseButton"), + ..._showcaseBuilder( + (variant, color) => ImmichCloseButton(color: color, variant: variant, onPressed: () {}), + ), + const _ComponentTitle("TextButton"), + + ImmichTextButton( + labelText: "Text Button", + onPressed: () {}, + variant: ImmichVariant.filled, + color: ImmichColor.primary, + ), + ImmichTextButton( + labelText: "Text Button", + onPressed: () {}, + variant: ImmichVariant.filled, + color: ImmichColor.primary, + loading: true, + ), + ImmichTextButton( + labelText: "Text Button", + onPressed: () {}, + variant: ImmichVariant.ghost, + color: ImmichColor.primary, + ), + ImmichTextButton( + labelText: "Text Button", + onPressed: () {}, + variant: ImmichVariant.ghost, + color: ImmichColor.primary, + loading: true, + ), + const _ComponentTitle("Form"), + ImmichForm( + onSubmit: () {}, + child: const Column( + spacing: 10, + children: [ImmichTextInput(label: "Title", hintText: "Enter a title")], + ), ), - Text("CloseButton", style: context.textTheme.titleLarge), - ..._showcaseBuilder((variant, color) => ImmichCloseButton(color: color, variant: variant, onTap: () {})), ], ), ), diff --git a/mobile/lib/presentation/pages/editing/drift_crop.page.dart b/mobile/lib/presentation/pages/editing/drift_crop.page.dart index 1692140cd2..a213e4c640 100644 --- a/mobile/lib/presentation/pages/editing/drift_crop.page.dart +++ b/mobile/lib/presentation/pages/editing/drift_crop.page.dart @@ -37,7 +37,7 @@ class DriftCropImagePage extends HookWidget { icon: Icons.done_rounded, color: ImmichColor.primary, variant: ImmichVariant.ghost, - onTap: () async { + onPressed: () async { final croppedImage = await cropController.croppedImage(); unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true))); }, @@ -79,13 +79,13 @@ class DriftCropImagePage extends HookWidget { icon: Icons.rotate_left, variant: ImmichVariant.ghost, color: ImmichColor.secondary, - onTap: () => cropController.rotateLeft(), + onPressed: () => cropController.rotateLeft(), ), ImmichIconButton( icon: Icons.rotate_right, variant: ImmichVariant.ghost, color: ImmichColor.secondary, - onTap: () => cropController.rotateRight(), + onPressed: () => cropController.rotateRight(), ), ], ), diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 2a7ac9c7fe..279139a5b8 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -611,6 +611,7 @@ class _AssetViewerState extends ConsumerState { filterQuality: FilterQuality.high, maxScale: 1.0, basePosition: Alignment.center, + disableScaleGestures: true, child: SizedBox( width: ctx.width, height: ctx.height, diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart index 4edd6855a8..664cdb358d 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart @@ -68,7 +68,7 @@ class _SheetLocationDetailsState extends ConsumerState { return const SizedBox.shrink(); } - final remoteId = asset is LocalAsset ? asset.remoteId : (asset as RemoteAsset).id; + final remoteAsset = asset as RemoteAsset; final locationName = _getLocationName(exifInfo); final coordinates = "${exifInfo?.latitude?.toStringAsFixed(4)}, ${exifInfo?.longitude?.toStringAsFixed(4)}"; @@ -92,7 +92,12 @@ class _SheetLocationDetailsState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ExifMap(exifInfo: exifInfo!, markerId: remoteId, onMapCreated: _onMapCreated), + ExifMap( + exifInfo: exifInfo!, + markerId: remoteAsset.id, + markerAssetThumbhash: remoteAsset.thumbHash, + onMapCreated: _onMapCreated, + ), const SizedBox(height: 16), if (locationName != null) Padding( diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index 7a063a8672..d9a736861f 100644 --- a/mobile/lib/presentation/widgets/images/remote_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_ import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; +import 'package:openapi/api.dart'; class RemoteThumbProvider extends CancellableImageProvider with CancellableImageProviderMixin { @@ -93,7 +94,7 @@ class RemoteFullImageProvider extends CancellableImageProvider - '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${AssetMediaSize.preview}'; - String getPlaybackUrlForRemoteId(final String id) { return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/video/playback?'; } diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/asset_location.dart b/mobile/lib/widgets/asset_viewer/detail_panel/asset_location.dart index 7ad290c152..6edf226e8b 100644 --- a/mobile/lib/widgets/asset_viewer/detail_panel/asset_location.dart +++ b/mobile/lib/widgets/asset_viewer/detail_panel/asset_location.dart @@ -74,7 +74,7 @@ class AssetLocation extends HookConsumerWidget { ], ), asset.isRemote ? const SizedBox.shrink() : const SizedBox(height: 16), - ExifMap(exifInfo: exifInfo!, markerId: asset.remoteId), + ExifMap(exifInfo: exifInfo!, markerId: asset.remoteId, markerAssetThumbhash: asset.thumbhash), const SizedBox(height: 16), getLocationName(), Text( diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart b/mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart index 893e534084..f48ee06fdd 100644 --- a/mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart +++ b/mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart @@ -10,10 +10,20 @@ import 'package:url_launcher/url_launcher.dart'; class ExifMap extends StatelessWidget { final ExifInfo exifInfo; + // TODO: Pass in a BaseAsset instead of the ID and thumbhash when removing old timeline + // This is currently structured this way because of the old timeline implementation + // reusing this component final String? markerId; + final String? markerAssetThumbhash; final MapCreatedCallback? onMapCreated; - const ExifMap({super.key, required this.exifInfo, this.markerId = 'marker', this.onMapCreated}); + const ExifMap({ + super.key, + required this.exifInfo, + this.markerAssetThumbhash, + this.markerId = 'marker', + this.onMapCreated, + }); @override Widget build(BuildContext context) { @@ -61,6 +71,7 @@ class ExifMap extends StatelessWidget { width: constraints.maxWidth, zoom: 12.0, assetMarkerRemoteId: markerId, + assetThumbhash: markerAssetThumbhash, onTap: (tapPosition, latLong) async { Uri? uri = await createCoordinatesUri(); diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index f810973298..71086fd803 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -14,6 +14,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; @@ -29,12 +30,7 @@ import 'package:immich_mobile/utils/version_compatibility.dart'; import 'package:immich_mobile/widgets/common/immich_logo.dart'; import 'package:immich_mobile/widgets/common/immich_title_text.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; -import 'package:immich_mobile/widgets/forms/login/email_input.dart'; -import 'package:immich_mobile/widgets/forms/login/loading_icon.dart'; -import 'package:immich_mobile/widgets/forms/login/login_button.dart'; -import 'package:immich_mobile/widgets/forms/login/o_auth_login_button.dart'; -import 'package:immich_mobile/widgets/forms/login/password_input.dart'; -import 'package:immich_mobile/widgets/forms/login/server_endpoint_input.dart'; +import 'package:immich_ui/immich_ui.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -45,16 +41,33 @@ class LoginForm extends HookConsumerWidget { final log = Logger('LoginForm'); + String? _validateUrl(String? url) { + if (url == null || url.isEmpty) return null; + + final parsedUrl = Uri.tryParse(url); + if (parsedUrl == null || !parsedUrl.isAbsolute || !parsedUrl.scheme.startsWith("http") || parsedUrl.host.isEmpty) { + return 'login_form_err_invalid_url'.tr(); + } + + return null; + } + + String? _validateEmail(String? email) { + if (email == null || email == '') return null; + if (email.endsWith(' ')) return 'login_form_err_trailing_whitespace'.tr(); + if (email.startsWith(' ')) return 'login_form_err_leading_whitespace'.tr(); + if (email.contains(' ') || !email.contains('@')) { + return 'login_form_err_invalid_email'.tr(); + } + return null; + } + @override Widget build(BuildContext context, WidgetRef ref) { final emailController = useTextEditingController.fromValue(TextEditingValue.empty); final passwordController = useTextEditingController.fromValue(TextEditingValue.empty); final serverEndpointController = useTextEditingController.fromValue(TextEditingValue.empty); - final emailFocusNode = useFocusNode(); final passwordFocusNode = useFocusNode(); - final serverEndpointFocusNode = useFocusNode(); - final isLoading = useState(false); - final isLoadingServer = useState(false); final isOauthEnable = useState(false); final isPasswordLoginEnable = useState(false); final oAuthButtonLabel = useState('OAuth'); @@ -96,7 +109,6 @@ class LoginForm extends HookConsumerWidget { } try { - isLoadingServer.value = true; final endpoint = await ref.read(authProvider.notifier).validateServerUrl(serverUrl); // Fetch and load server config and features @@ -120,7 +132,6 @@ class LoginForm extends HookConsumerWidget { ); isOauthEnable.value = false; isPasswordLoginEnable.value = true; - isLoadingServer.value = false; } on HandshakeException { ImmichToast.show( context: context, @@ -130,7 +141,6 @@ class LoginForm extends HookConsumerWidget { ); isOauthEnable.value = false; isPasswordLoginEnable.value = true; - isLoadingServer.value = false; } catch (e) { ImmichToast.show( context: context, @@ -140,10 +150,7 @@ class LoginForm extends HookConsumerWidget { ); isOauthEnable.value = false; isPasswordLoginEnable.value = true; - isLoadingServer.value = false; } - - isLoadingServer.value = false; } useEffect(() { @@ -230,8 +237,6 @@ class LoginForm extends HookConsumerWidget { login() async { TextInput.finishAutofillContext(); - isLoading.value = true; - // Invalidate all api repository provider instance to take into account new access token invalidateAllApiRepositoryProviders(ref); @@ -261,8 +266,6 @@ class LoginForm extends HookConsumerWidget { toastType: ToastType.error, gravity: ToastGravity.TOP, ); - } finally { - isLoading.value = false; } } @@ -306,8 +309,6 @@ class LoginForm extends HookConsumerWidget { codeChallenge, ); - isLoading.value = true; - // Invalidate all api repository provider instance to take into account new access token invalidateAllApiRepositoryProviders(ref); } catch (error, stack) { @@ -319,7 +320,6 @@ class LoginForm extends HookConsumerWidget { toastType: ToastType.error, gravity: ToastGravity.TOP, ); - isLoading.value = false; return; } @@ -338,7 +338,6 @@ class LoginForm extends HookConsumerWidget { .saveAuthInfo(accessToken: loginResponseDto.accessToken); if (isSuccess) { - isLoading.value = false; final permission = ref.watch(galleryPermissionNotifier); final isBeta = Store.isBetaTimelineEnabled; if (!isBeta && (permission.isGranted || permission.isLimited)) { @@ -364,9 +363,7 @@ class LoginForm extends HookConsumerWidget { toastType: ToastType.error, gravity: ToastGravity.TOP, ); - } finally { - isLoading.value = false; - } + } finally {} } else { ImmichToast.show( context: context, @@ -374,66 +371,10 @@ class LoginForm extends HookConsumerWidget { toastType: ToastType.info, gravity: ToastGravity.TOP, ); - isLoading.value = false; return; } } - buildSelectServer() { - const buttonRadius = 25.0; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ServerEndpointInput( - controller: serverEndpointController, - focusNode: serverEndpointFocusNode, - onSubmit: getServerAuthSettings, - ), - const SizedBox(height: 18), - Row( - children: [ - Expanded( - child: ElevatedButton.icon( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(buttonRadius), - bottomLeft: Radius.circular(buttonRadius), - ), - ), - ), - onPressed: () => context.pushRoute(const SettingsRoute()), - icon: const Icon(Icons.settings_rounded), - label: const Text(""), - ), - ), - const SizedBox(width: 1), - Expanded( - flex: 3, - child: ElevatedButton.icon( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topRight: Radius.circular(buttonRadius), - bottomRight: Radius.circular(buttonRadius), - ), - ), - ), - onPressed: isLoadingServer.value ? null : getServerAuthSettings, - icon: const Icon(Icons.arrow_forward_rounded), - label: const Text('next', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(), - ), - ), - ], - ), - const SizedBox(height: 18), - if (isLoadingServer.value) const LoadingIcon(), - ], - ); - } - buildVersionCompatWarning() { checkVersionMismatch(); @@ -455,66 +396,102 @@ class LoginForm extends HookConsumerWidget { ); } - buildLogin() { - return AutofillGroup( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - buildVersionCompatWarning(), - Text( - sanitizeUrl(serverEndpointController.text), - style: context.textTheme.displaySmall, - textAlign: TextAlign.center, + final serverSelectionOrLogin = serverEndpoint.value == null + ? Padding( + padding: const EdgeInsets.only(top: ImmichSpacing.md), + child: Column( + mainAxisSize: MainAxisSize.max, + children: [ + ImmichForm( + submitText: 'next'.t(context: context), + submitIcon: Icons.arrow_forward_rounded, + onSubmit: getServerAuthSettings, + child: ImmichTextInput( + controller: serverEndpointController, + label: 'login_form_endpoint_url'.t(context: context), + hintText: 'login_form_endpoint_hint'.t(context: context), + validator: _validateUrl, + keyboardAction: TextInputAction.next, + keyboardType: TextInputType.url, + autofillHints: const [AutofillHints.url], + onSubmit: (ctx, _) => ImmichForm.of(ctx).submit(), + ), + ), + ImmichTextButton( + labelText: 'settings'.t(context: context), + icon: Icons.settings, + variant: ImmichVariant.ghost, + onPressed: () => context.pushRoute(const SettingsRoute()), + ), + ], ), - if (isPasswordLoginEnable.value) ...[ - const SizedBox(height: 18), - EmailInput( - controller: emailController, - focusNode: emailFocusNode, - onSubmit: passwordFocusNode.requestFocus, - ), - const SizedBox(height: 8), - PasswordInput(controller: passwordController, focusNode: passwordFocusNode, onSubmit: login), - ], - - // Note: This used to have an AnimatedSwitcher, but was removed - // because of https://github.com/flutter/flutter/issues/120874 - isLoading.value - ? const LoadingIcon() - : Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox(height: 18), - if (isPasswordLoginEnable.value) LoginButton(onPressed: login), - if (isOauthEnable.value) ...[ - if (isPasswordLoginEnable.value) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black), - ), - OAuthLoginButton( - serverEndpointController: serverEndpointController, - buttonLabel: oAuthButtonLabel.value, - isLoading: isLoading, - onPressed: oAuthLogin, + ) + : AutofillGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.max, + children: [ + buildVersionCompatWarning(), + Padding( + padding: const EdgeInsets.only(bottom: ImmichSpacing.md), + child: Text( + sanitizeUrl(serverEndpointController.text), + style: context.textTheme.displaySmall, + textAlign: TextAlign.center, + ), + ), + if (isPasswordLoginEnable.value) + ImmichForm( + submitText: 'login'.t(context: context), + submitIcon: Icons.login_rounded, + onSubmit: login, + child: Column( + spacing: ImmichSpacing.md, + children: [ + ImmichTextInput( + controller: emailController, + label: 'email'.t(context: context), + hintText: 'login_form_email_hint'.t(context: context), + validator: _validateEmail, + keyboardAction: TextInputAction.next, + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.email], + onSubmit: (_, _) => passwordFocusNode.requestFocus(), + ), + ImmichPasswordInput( + controller: passwordController, + focusNode: passwordFocusNode, + label: 'password'.t(context: context), + hintText: 'login_form_password_hint'.t(context: context), + keyboardAction: TextInputAction.go, + onSubmit: (ctx, _) => ImmichForm.of(ctx).submit(), ), ], - ], + ), ), - if (!isOauthEnable.value && !isPasswordLoginEnable.value) Center(child: const Text('login_disabled').tr()), - const SizedBox(height: 12), - TextButton.icon( - icon: const Icon(Icons.arrow_back), - onPressed: () => serverEndpoint.value = null, - label: const Text('back').tr(), + if (isOauthEnable.value) + ImmichForm( + submitText: oAuthButtonLabel.value, + submitIcon: Icons.pin_outlined, + onSubmit: oAuthLogin, + child: isPasswordLoginEnable.value + ? Padding( + padding: const EdgeInsets.only(left: 18.0, right: 18.0, top: 12.0), + child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black, height: 5), + ) + : const SizedBox.shrink(), + ), + if (!isOauthEnable.value && !isPasswordLoginEnable.value) + Center(child: const Text('login_disabled').tr()), + ImmichTextButton( + labelText: 'back'.t(context: context), + icon: Icons.arrow_back, + variant: ImmichVariant.ghost, + onPressed: () => serverEndpoint.value = null, + ), + ], ), - ], - ), - ); - } - - final serverSelectionOrLogin = serverEndpoint.value == null ? buildSelectServer() : buildLogin(); + ); return LayoutBuilder( builder: (context, constraints) { diff --git a/mobile/lib/widgets/forms/login/o_auth_login_button.dart b/mobile/lib/widgets/forms/login/o_auth_login_button.dart deleted file mode 100644 index 2d9b603b3c..0000000000 --- a/mobile/lib/widgets/forms/login/o_auth_login_button.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; - -class OAuthLoginButton extends ConsumerWidget { - final TextEditingController serverEndpointController; - final ValueNotifier isLoading; - final String buttonLabel; - final Function() onPressed; - - const OAuthLoginButton({ - super.key, - required this.serverEndpointController, - required this.isLoading, - required this.buttonLabel, - required this.onPressed, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return ElevatedButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: context.primaryColor.withAlpha(230), - padding: const EdgeInsets.symmetric(vertical: 12), - ), - onPressed: onPressed, - icon: const Icon(Icons.pin_rounded), - label: Text(buttonLabel, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), - ); - } -} diff --git a/mobile/lib/widgets/forms/login/password_input.dart b/mobile/lib/widgets/forms/login/password_input.dart deleted file mode 100644 index 5cdfcc9567..0000000000 --- a/mobile/lib/widgets/forms/login/password_input.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -class PasswordInput extends HookConsumerWidget { - final TextEditingController controller; - final FocusNode? focusNode; - final Function()? onSubmit; - - const PasswordInput({super.key, required this.controller, this.focusNode, this.onSubmit}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final isPasswordVisible = useState(false); - - return TextFormField( - obscureText: !isPasswordVisible.value, - controller: controller, - decoration: InputDecoration( - labelText: 'password'.tr(), - border: const OutlineInputBorder(), - hintText: 'login_form_password_hint'.tr(), - hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14), - suffixIcon: IconButton( - onPressed: () => isPasswordVisible.value = !isPasswordVisible.value, - icon: Icon(isPasswordVisible.value ? Icons.visibility_off_sharp : Icons.visibility_sharp), - ), - ), - autofillHints: const [AutofillHints.password], - keyboardType: TextInputType.text, - onFieldSubmitted: (_) => onSubmit?.call(), - focusNode: focusNode, - textInputAction: TextInputAction.go, - ); - } -} diff --git a/mobile/lib/widgets/forms/login/server_endpoint_input.dart b/mobile/lib/widgets/forms/login/server_endpoint_input.dart deleted file mode 100644 index f9bc1690af..0000000000 --- a/mobile/lib/widgets/forms/login/server_endpoint_input.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:immich_mobile/utils/url_helper.dart'; - -class ServerEndpointInput extends StatelessWidget { - final TextEditingController controller; - final FocusNode focusNode; - final Function()? onSubmit; - - const ServerEndpointInput({super.key, required this.controller, required this.focusNode, this.onSubmit}); - - String? _validateInput(String? url) { - if (url == null || url.isEmpty) return null; - - final parsedUrl = Uri.tryParse(sanitizeUrl(url)); - if (parsedUrl == null || !parsedUrl.isAbsolute || !parsedUrl.scheme.startsWith("http") || parsedUrl.host.isEmpty) { - return 'login_form_err_invalid_url'.tr(); - } - - return null; - } - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(top: 16.0), - child: TextFormField( - controller: controller, - decoration: InputDecoration( - labelText: 'login_form_endpoint_url'.tr(), - border: const OutlineInputBorder(), - hintText: 'login_form_endpoint_hint'.tr(), - errorMaxLines: 4, - ), - validator: _validateInput, - autovalidateMode: AutovalidateMode.always, - focusNode: focusNode, - autofillHints: const [AutofillHints.url], - keyboardType: TextInputType.url, - autocorrect: false, - onFieldSubmitted: (_) => onSubmit?.call(), - textInputAction: TextInputAction.go, - ), - ); - } -} diff --git a/mobile/lib/widgets/map/map_thumbnail.dart b/mobile/lib/widgets/map/map_thumbnail.dart index 55f5ff77c6..32d90a28d9 100644 --- a/mobile/lib/widgets/map/map_thumbnail.dart +++ b/mobile/lib/widgets/map/map_thumbnail.dart @@ -19,6 +19,7 @@ class MapThumbnail extends HookConsumerWidget { final Function(Point, LatLng)? onTap; final LatLng centre; final String? assetMarkerRemoteId; + final String? assetThumbhash; final bool showMarkerPin; final double zoom; final double height; @@ -35,6 +36,7 @@ class MapThumbnail extends HookConsumerWidget { this.onTap, this.zoom = 8, this.assetMarkerRemoteId, + this.assetThumbhash, this.showMarkerPin = false, this.themeMode, this.showAttribution = true, @@ -109,8 +111,13 @@ class MapThumbnail extends HookConsumerWidget { ), ValueListenableBuilder( valueListenable: position, - builder: (_, value, __) => value != null && assetMarkerRemoteId != null - ? PositionedAssetMarkerIcon(size: height / 2, point: value, assetRemoteId: assetMarkerRemoteId!) + builder: (_, value, __) => value != null && assetMarkerRemoteId != null && assetThumbhash != null + ? PositionedAssetMarkerIcon( + size: height / 2, + point: value, + assetRemoteId: assetMarkerRemoteId!, + assetThumbhash: assetThumbhash!, + ) : const SizedBox.shrink(), ), ], diff --git a/mobile/lib/widgets/map/positioned_asset_marker_icon.dart b/mobile/lib/widgets/map/positioned_asset_marker_icon.dart index 0944f7ce3e..becef728da 100644 --- a/mobile/lib/widgets/map/positioned_asset_marker_icon.dart +++ b/mobile/lib/widgets/map/positioned_asset_marker_icon.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/utils/image_url_builder.dart'; class PositionedAssetMarkerIcon extends StatelessWidget { final Point point; final String assetRemoteId; + final String assetThumbhash; final double size; final int durationInMilliseconds; @@ -18,6 +19,7 @@ class PositionedAssetMarkerIcon extends StatelessWidget { const PositionedAssetMarkerIcon({ required this.point, required this.assetRemoteId, + required this.assetThumbhash, this.size = 100, this.durationInMilliseconds = 100, this.onTap, @@ -35,7 +37,7 @@ class PositionedAssetMarkerIcon extends StatelessWidget { onTap: () => onTap?.call(), child: SizedBox.square( dimension: size, - child: _AssetMarkerIcon(id: assetRemoteId, key: Key(assetRemoteId)), + child: _AssetMarkerIcon(id: assetRemoteId, thumbhash: assetThumbhash, key: Key(assetRemoteId)), ), ), ); @@ -43,14 +45,15 @@ class PositionedAssetMarkerIcon extends StatelessWidget { } class _AssetMarkerIcon extends StatelessWidget { - const _AssetMarkerIcon({required this.id, super.key}); + const _AssetMarkerIcon({required this.id, required this.thumbhash, super.key}); final String id; + final String thumbhash; @override Widget build(BuildContext context) { final imageUrl = getThumbnailUrlForRemoteId(id); - final cacheKey = getThumbnailCacheKeyForRemoteId(id); + final cacheKey = getThumbnailCacheKeyForRemoteId(id, thumbhash); return LayoutBuilder( builder: (context, constraints) { return Stack( diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index 5020afc4b2..03d91c9dae 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -186,12 +186,12 @@ class AssetsApi { /// /// * [String] id (required): /// - /// * [AssetMetadataKey] key (required): - Future deleteAssetMetadataWithHttpInfo(String id, AssetMetadataKey key,) async { + /// * [String] key (required): + Future deleteAssetMetadataWithHttpInfo(String id, String key,) async { // ignore: prefer_const_declarations final apiPath = r'/assets/{id}/metadata/{key}' .replaceAll('{id}', id) - .replaceAll('{key}', key.toString()); + .replaceAll('{key}', key); // ignore: prefer_final_locals Object? postBody; @@ -222,8 +222,8 @@ class AssetsApi { /// /// * [String] id (required): /// - /// * [AssetMetadataKey] key (required): - Future deleteAssetMetadata(String id, AssetMetadataKey key,) async { + /// * [String] key (required): + Future deleteAssetMetadata(String id, String key,) async { final response = await deleteAssetMetadataWithHttpInfo(id, key,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); @@ -278,6 +278,54 @@ class AssetsApi { } } + /// Delete asset metadata + /// + /// Delete metadata key-value pairs for multiple assets. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [AssetMetadataBulkDeleteDto] assetMetadataBulkDeleteDto (required): + Future deleteBulkAssetMetadataWithHttpInfo(AssetMetadataBulkDeleteDto assetMetadataBulkDeleteDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/assets/metadata'; + + // ignore: prefer_final_locals + Object? postBody = assetMetadataBulkDeleteDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Delete asset metadata + /// + /// Delete metadata key-value pairs for multiple assets. + /// + /// Parameters: + /// + /// * [AssetMetadataBulkDeleteDto] assetMetadataBulkDeleteDto (required): + Future deleteBulkAssetMetadata(AssetMetadataBulkDeleteDto assetMetadataBulkDeleteDto,) async { + final response = await deleteBulkAssetMetadataWithHttpInfo(assetMetadataBulkDeleteDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Download original asset /// /// Downloads the original file of the specified asset. @@ -288,10 +336,12 @@ class AssetsApi { /// /// * [String] id (required): /// + /// * [bool] edited: + /// /// * [String] key: /// /// * [String] slug: - Future downloadAssetWithHttpInfo(String id, { String? key, String? slug, }) async { + Future downloadAssetWithHttpInfo(String id, { bool? edited, String? key, String? slug, }) async { // ignore: prefer_const_declarations final apiPath = r'/assets/{id}/original' .replaceAll('{id}', id); @@ -303,6 +353,9 @@ class AssetsApi { final headerParams = {}; final formParams = {}; + if (edited != null) { + queryParams.addAll(_queryParams('', 'edited', edited)); + } if (key != null) { queryParams.addAll(_queryParams('', 'key', key)); } @@ -332,11 +385,13 @@ class AssetsApi { /// /// * [String] id (required): /// + /// * [bool] edited: + /// /// * [String] key: /// /// * [String] slug: - Future downloadAsset(String id, { String? key, String? slug, }) async { - final response = await downloadAssetWithHttpInfo(id, key: key, slug: slug, ); + Future downloadAsset(String id, { bool? edited, String? key, String? slug, }) async { + final response = await downloadAssetWithHttpInfo(id, edited: edited, key: key, slug: slug, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -350,6 +405,67 @@ class AssetsApi { return null; } + /// Apply edits to an existing asset + /// + /// Apply a series of edit actions (crop, rotate, mirror) to the specified asset. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [AssetEditActionListDto] assetEditActionListDto (required): + Future editAssetWithHttpInfo(String id, AssetEditActionListDto assetEditActionListDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/assets/{id}/edits' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody = assetEditActionListDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Apply edits to an existing asset + /// + /// Apply a series of edit actions (crop, rotate, mirror) to the specified asset. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [AssetEditActionListDto] assetEditActionListDto (required): + Future editAsset(String id, AssetEditActionListDto assetEditActionListDto,) async { + final response = await editAssetWithHttpInfo(id, assetEditActionListDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetEditsDto',) as AssetEditsDto; + + } + return null; + } + /// Retrieve assets by device ID /// /// Get all asset of a device that are in the database, ID only. @@ -410,6 +526,63 @@ class AssetsApi { return null; } + /// Retrieve edits for an existing asset + /// + /// Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + Future getAssetEditsWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/assets/{id}/edits' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Retrieve edits for an existing asset + /// + /// Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset. + /// + /// Parameters: + /// + /// * [String] id (required): + Future getAssetEdits(String id,) async { + final response = await getAssetEditsWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetEditsDto',) as AssetEditsDto; + + } + return null; + } + /// Retrieve an asset /// /// Retrieve detailed information about a specific asset. @@ -552,12 +725,12 @@ class AssetsApi { /// /// * [String] id (required): /// - /// * [AssetMetadataKey] key (required): - Future getAssetMetadataByKeyWithHttpInfo(String id, AssetMetadataKey key,) async { + /// * [String] key (required): + Future getAssetMetadataByKeyWithHttpInfo(String id, String key,) async { // ignore: prefer_const_declarations final apiPath = r'/assets/{id}/metadata/{key}' .replaceAll('{id}', id) - .replaceAll('{key}', key.toString()); + .replaceAll('{key}', key); // ignore: prefer_final_locals Object? postBody; @@ -588,8 +761,8 @@ class AssetsApi { /// /// * [String] id (required): /// - /// * [AssetMetadataKey] key (required): - Future getAssetMetadataByKey(String id, AssetMetadataKey key,) async { + /// * [String] key (required): + Future getAssetMetadataByKey(String id, String key,) async { final response = await getAssetMetadataByKeyWithHttpInfo(id, key,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); @@ -873,6 +1046,55 @@ class AssetsApi { return null; } + /// Remove edits from an existing asset + /// + /// Removes all edit actions (crop, rotate, mirror) associated with the specified asset. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + Future removeAssetEditsWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/assets/{id}/edits' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Remove edits from an existing asset + /// + /// Removes all edit actions (crop, rotate, mirror) associated with the specified asset. + /// + /// Parameters: + /// + /// * [String] id (required): + Future removeAssetEdits(String id,) async { + final response = await removeAssetEditsWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Replace asset /// /// Replace the asset with new file, without changing its id. @@ -1228,6 +1450,65 @@ class AssetsApi { } } + /// Upsert asset metadata + /// + /// Upsert metadata key-value pairs for multiple assets. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [AssetMetadataBulkUpsertDto] assetMetadataBulkUpsertDto (required): + Future updateBulkAssetMetadataWithHttpInfo(AssetMetadataBulkUpsertDto assetMetadataBulkUpsertDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/assets/metadata'; + + // ignore: prefer_final_locals + Object? postBody = assetMetadataBulkUpsertDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Upsert asset metadata + /// + /// Upsert metadata key-value pairs for multiple assets. + /// + /// Parameters: + /// + /// * [AssetMetadataBulkUpsertDto] assetMetadataBulkUpsertDto (required): + Future?> updateBulkAssetMetadata(AssetMetadataBulkUpsertDto assetMetadataBulkUpsertDto,) async { + final response = await updateBulkAssetMetadataWithHttpInfo(assetMetadataBulkUpsertDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + /// Upload asset /// /// Uploads a new asset to the server. @@ -1246,8 +1527,6 @@ class AssetsApi { /// /// * [DateTime] fileModifiedAt (required): /// - /// * [List] metadata (required): - /// /// * [String] key: /// /// * [String] slug: @@ -1263,10 +1542,12 @@ class AssetsApi { /// /// * [String] livePhotoVideoId: /// + /// * [List] metadata: + /// /// * [MultipartFile] sidecarData: /// /// * [AssetVisibility] visibility: - Future uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, List metadata, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { + Future uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { // ignore: prefer_const_declarations final apiPath = r'/assets'; @@ -1373,8 +1654,6 @@ class AssetsApi { /// /// * [DateTime] fileModifiedAt (required): /// - /// * [List] metadata (required): - /// /// * [String] key: /// /// * [String] slug: @@ -1390,11 +1669,13 @@ class AssetsApi { /// /// * [String] livePhotoVideoId: /// + /// * [List] metadata: + /// /// * [MultipartFile] sidecarData: /// /// * [AssetVisibility] visibility: - Future uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, List metadata, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { - final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, metadata, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, visibility: visibility, ); + Future uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { + final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, visibility: visibility, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -1418,12 +1699,14 @@ class AssetsApi { /// /// * [String] id (required): /// + /// * [bool] edited: + /// /// * [String] key: /// /// * [AssetMediaSize] size: /// /// * [String] slug: - Future viewAssetWithHttpInfo(String id, { String? key, AssetMediaSize? size, String? slug, }) async { + Future viewAssetWithHttpInfo(String id, { bool? edited, String? key, AssetMediaSize? size, String? slug, }) async { // ignore: prefer_const_declarations final apiPath = r'/assets/{id}/thumbnail' .replaceAll('{id}', id); @@ -1435,6 +1718,9 @@ class AssetsApi { final headerParams = {}; final formParams = {}; + if (edited != null) { + queryParams.addAll(_queryParams('', 'edited', edited)); + } if (key != null) { queryParams.addAll(_queryParams('', 'key', key)); } @@ -1467,13 +1753,15 @@ class AssetsApi { /// /// * [String] id (required): /// + /// * [bool] edited: + /// /// * [String] key: /// /// * [AssetMediaSize] size: /// /// * [String] slug: - Future viewAsset(String id, { String? key, AssetMediaSize? size, String? slug, }) async { - final response = await viewAssetWithHttpInfo(id, key: key, size: size, slug: slug, ); + Future viewAsset(String id, { bool? edited, String? key, AssetMediaSize? size, String? slug, }) async { + final response = await viewAssetWithHttpInfo(id, edited: edited, key: key, size: size, slug: slug, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/model/asset_edit_action.dart b/mobile/openapi/lib/model/asset_edit_action.dart new file mode 100644 index 0000000000..12aacfb68a --- /dev/null +++ b/mobile/openapi/lib/model/asset_edit_action.dart @@ -0,0 +1,88 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class AssetEditAction { + /// Instantiate a new enum with the provided [value]. + const AssetEditAction._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const crop = AssetEditAction._(r'crop'); + static const rotate = AssetEditAction._(r'rotate'); + static const mirror = AssetEditAction._(r'mirror'); + + /// List of all possible values in this [enum][AssetEditAction]. + static const values = [ + crop, + rotate, + mirror, + ]; + + static AssetEditAction? fromJson(dynamic value) => AssetEditActionTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetEditAction.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [AssetEditAction] to String, +/// and [decode] dynamic data back to [AssetEditAction]. +class AssetEditActionTypeTransformer { + factory AssetEditActionTypeTransformer() => _instance ??= const AssetEditActionTypeTransformer._(); + + const AssetEditActionTypeTransformer._(); + + String encode(AssetEditAction data) => data.value; + + /// Decodes a [dynamic value][data] to a AssetEditAction. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + AssetEditAction? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'crop': return AssetEditAction.crop; + case r'rotate': return AssetEditAction.rotate; + case r'mirror': return AssetEditAction.mirror; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [AssetEditActionTypeTransformer] instance. + static AssetEditActionTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/asset_edit_action_crop.dart b/mobile/openapi/lib/model/asset_edit_action_crop.dart new file mode 100644 index 0000000000..3b55a105d9 --- /dev/null +++ b/mobile/openapi/lib/model/asset_edit_action_crop.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetEditActionCrop { + /// Returns a new [AssetEditActionCrop] instance. + AssetEditActionCrop({ + required this.action, + required this.parameters, + }); + + AssetEditAction action; + + CropParameters parameters; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetEditActionCrop && + other.action == action && + other.parameters == parameters; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (action.hashCode) + + (parameters.hashCode); + + @override + String toString() => 'AssetEditActionCrop[action=$action, parameters=$parameters]'; + + Map toJson() { + final json = {}; + json[r'action'] = this.action; + json[r'parameters'] = this.parameters; + return json; + } + + /// Returns a new [AssetEditActionCrop] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetEditActionCrop? fromJson(dynamic value) { + upgradeDto(value, "AssetEditActionCrop"); + if (value is Map) { + final json = value.cast(); + + return AssetEditActionCrop( + action: AssetEditAction.fromJson(json[r'action'])!, + parameters: CropParameters.fromJson(json[r'parameters'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetEditActionCrop.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetEditActionCrop.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetEditActionCrop-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetEditActionCrop.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'action', + 'parameters', + }; +} + diff --git a/mobile/openapi/lib/model/asset_edit_action_list_dto.dart b/mobile/openapi/lib/model/asset_edit_action_list_dto.dart new file mode 100644 index 0000000000..48c1e15922 --- /dev/null +++ b/mobile/openapi/lib/model/asset_edit_action_list_dto.dart @@ -0,0 +1,100 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetEditActionListDto { + /// Returns a new [AssetEditActionListDto] instance. + AssetEditActionListDto({ + this.edits = const [], + }); + + /// list of edits + List edits; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetEditActionListDto && + _deepEquality.equals(other.edits, edits); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (edits.hashCode); + + @override + String toString() => 'AssetEditActionListDto[edits=$edits]'; + + Map toJson() { + final json = {}; + json[r'edits'] = this.edits; + return json; + } + + /// Returns a new [AssetEditActionListDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetEditActionListDto? fromJson(dynamic value) { + upgradeDto(value, "AssetEditActionListDto"); + if (value is Map) { + final json = value.cast(); + + return AssetEditActionListDto( + edits: AssetEditActionListDtoEditsInner.listFromJson(json[r'edits']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetEditActionListDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetEditActionListDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetEditActionListDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetEditActionListDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'edits', + }; +} + diff --git a/mobile/openapi/lib/model/asset_edit_action_list_dto_edits_inner.dart b/mobile/openapi/lib/model/asset_edit_action_list_dto_edits_inner.dart new file mode 100644 index 0000000000..c4c0496631 --- /dev/null +++ b/mobile/openapi/lib/model/asset_edit_action_list_dto_edits_inner.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetEditActionListDtoEditsInner { + /// Returns a new [AssetEditActionListDtoEditsInner] instance. + AssetEditActionListDtoEditsInner({ + required this.action, + required this.parameters, + }); + + AssetEditAction action; + + MirrorParameters parameters; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetEditActionListDtoEditsInner && + other.action == action && + other.parameters == parameters; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (action.hashCode) + + (parameters.hashCode); + + @override + String toString() => 'AssetEditActionListDtoEditsInner[action=$action, parameters=$parameters]'; + + Map toJson() { + final json = {}; + json[r'action'] = this.action; + json[r'parameters'] = this.parameters; + return json; + } + + /// Returns a new [AssetEditActionListDtoEditsInner] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetEditActionListDtoEditsInner? fromJson(dynamic value) { + upgradeDto(value, "AssetEditActionListDtoEditsInner"); + if (value is Map) { + final json = value.cast(); + + return AssetEditActionListDtoEditsInner( + action: AssetEditAction.fromJson(json[r'action'])!, + parameters: MirrorParameters.fromJson(json[r'parameters'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetEditActionListDtoEditsInner.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetEditActionListDtoEditsInner.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetEditActionListDtoEditsInner-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetEditActionListDtoEditsInner.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'action', + 'parameters', + }; +} + diff --git a/mobile/openapi/lib/model/asset_edit_action_mirror.dart b/mobile/openapi/lib/model/asset_edit_action_mirror.dart new file mode 100644 index 0000000000..782d317b7b --- /dev/null +++ b/mobile/openapi/lib/model/asset_edit_action_mirror.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetEditActionMirror { + /// Returns a new [AssetEditActionMirror] instance. + AssetEditActionMirror({ + required this.action, + required this.parameters, + }); + + AssetEditAction action; + + MirrorParameters parameters; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetEditActionMirror && + other.action == action && + other.parameters == parameters; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (action.hashCode) + + (parameters.hashCode); + + @override + String toString() => 'AssetEditActionMirror[action=$action, parameters=$parameters]'; + + Map toJson() { + final json = {}; + json[r'action'] = this.action; + json[r'parameters'] = this.parameters; + return json; + } + + /// Returns a new [AssetEditActionMirror] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetEditActionMirror? fromJson(dynamic value) { + upgradeDto(value, "AssetEditActionMirror"); + if (value is Map) { + final json = value.cast(); + + return AssetEditActionMirror( + action: AssetEditAction.fromJson(json[r'action'])!, + parameters: MirrorParameters.fromJson(json[r'parameters'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetEditActionMirror.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetEditActionMirror.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetEditActionMirror-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetEditActionMirror.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'action', + 'parameters', + }; +} + diff --git a/mobile/openapi/lib/model/asset_edit_action_rotate.dart b/mobile/openapi/lib/model/asset_edit_action_rotate.dart new file mode 100644 index 0000000000..1104c06307 --- /dev/null +++ b/mobile/openapi/lib/model/asset_edit_action_rotate.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetEditActionRotate { + /// Returns a new [AssetEditActionRotate] instance. + AssetEditActionRotate({ + required this.action, + required this.parameters, + }); + + AssetEditAction action; + + RotateParameters parameters; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetEditActionRotate && + other.action == action && + other.parameters == parameters; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (action.hashCode) + + (parameters.hashCode); + + @override + String toString() => 'AssetEditActionRotate[action=$action, parameters=$parameters]'; + + Map toJson() { + final json = {}; + json[r'action'] = this.action; + json[r'parameters'] = this.parameters; + return json; + } + + /// Returns a new [AssetEditActionRotate] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetEditActionRotate? fromJson(dynamic value) { + upgradeDto(value, "AssetEditActionRotate"); + if (value is Map) { + final json = value.cast(); + + return AssetEditActionRotate( + action: AssetEditAction.fromJson(json[r'action'])!, + parameters: RotateParameters.fromJson(json[r'parameters'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetEditActionRotate.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetEditActionRotate.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetEditActionRotate-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetEditActionRotate.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'action', + 'parameters', + }; +} + diff --git a/mobile/openapi/lib/model/asset_edits_dto.dart b/mobile/openapi/lib/model/asset_edits_dto.dart new file mode 100644 index 0000000000..26757dce3b --- /dev/null +++ b/mobile/openapi/lib/model/asset_edits_dto.dart @@ -0,0 +1,108 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetEditsDto { + /// Returns a new [AssetEditsDto] instance. + AssetEditsDto({ + required this.assetId, + this.edits = const [], + }); + + String assetId; + + /// list of edits + List edits; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetEditsDto && + other.assetId == assetId && + _deepEquality.equals(other.edits, edits); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (edits.hashCode); + + @override + String toString() => 'AssetEditsDto[assetId=$assetId, edits=$edits]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + json[r'edits'] = this.edits; + return json; + } + + /// Returns a new [AssetEditsDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetEditsDto? fromJson(dynamic value) { + upgradeDto(value, "AssetEditsDto"); + if (value is Map) { + final json = value.cast(); + + return AssetEditsDto( + assetId: mapValueOfType(json, r'assetId')!, + edits: AssetEditActionListDtoEditsInner.listFromJson(json[r'edits']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetEditsDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetEditsDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetEditsDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetEditsDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'edits', + }; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_bulk_delete_dto.dart b/mobile/openapi/lib/model/asset_metadata_bulk_delete_dto.dart new file mode 100644 index 0000000000..23c34d7152 --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_bulk_delete_dto.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetMetadataBulkDeleteDto { + /// Returns a new [AssetMetadataBulkDeleteDto] instance. + AssetMetadataBulkDeleteDto({ + this.items = const [], + }); + + List items; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMetadataBulkDeleteDto && + _deepEquality.equals(other.items, items); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (items.hashCode); + + @override + String toString() => 'AssetMetadataBulkDeleteDto[items=$items]'; + + Map toJson() { + final json = {}; + json[r'items'] = this.items; + return json; + } + + /// Returns a new [AssetMetadataBulkDeleteDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMetadataBulkDeleteDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMetadataBulkDeleteDto"); + if (value is Map) { + final json = value.cast(); + + return AssetMetadataBulkDeleteDto( + items: AssetMetadataBulkDeleteItemDto.listFromJson(json[r'items']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetMetadataBulkDeleteDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetMetadataBulkDeleteDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMetadataBulkDeleteDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetMetadataBulkDeleteDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'items', + }; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_bulk_delete_item_dto.dart b/mobile/openapi/lib/model/asset_metadata_bulk_delete_item_dto.dart new file mode 100644 index 0000000000..a3a111f9f7 --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_bulk_delete_item_dto.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetMetadataBulkDeleteItemDto { + /// Returns a new [AssetMetadataBulkDeleteItemDto] instance. + AssetMetadataBulkDeleteItemDto({ + required this.assetId, + required this.key, + }); + + String assetId; + + String key; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMetadataBulkDeleteItemDto && + other.assetId == assetId && + other.key == key; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (key.hashCode); + + @override + String toString() => 'AssetMetadataBulkDeleteItemDto[assetId=$assetId, key=$key]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + json[r'key'] = this.key; + return json; + } + + /// Returns a new [AssetMetadataBulkDeleteItemDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMetadataBulkDeleteItemDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMetadataBulkDeleteItemDto"); + if (value is Map) { + final json = value.cast(); + + return AssetMetadataBulkDeleteItemDto( + assetId: mapValueOfType(json, r'assetId')!, + key: mapValueOfType(json, r'key')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetMetadataBulkDeleteItemDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetMetadataBulkDeleteItemDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMetadataBulkDeleteItemDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetMetadataBulkDeleteItemDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'key', + }; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_bulk_response_dto.dart b/mobile/openapi/lib/model/asset_metadata_bulk_response_dto.dart new file mode 100644 index 0000000000..15c130930b --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_bulk_response_dto.dart @@ -0,0 +1,123 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetMetadataBulkResponseDto { + /// Returns a new [AssetMetadataBulkResponseDto] instance. + AssetMetadataBulkResponseDto({ + required this.assetId, + required this.key, + required this.updatedAt, + required this.value, + }); + + String assetId; + + String key; + + DateTime updatedAt; + + Object value; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMetadataBulkResponseDto && + other.assetId == assetId && + other.key == key && + other.updatedAt == updatedAt && + other.value == value; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (key.hashCode) + + (updatedAt.hashCode) + + (value.hashCode); + + @override + String toString() => 'AssetMetadataBulkResponseDto[assetId=$assetId, key=$key, updatedAt=$updatedAt, value=$value]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + json[r'key'] = this.key; + json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); + json[r'value'] = this.value; + return json; + } + + /// Returns a new [AssetMetadataBulkResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMetadataBulkResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMetadataBulkResponseDto"); + if (value is Map) { + final json = value.cast(); + + return AssetMetadataBulkResponseDto( + assetId: mapValueOfType(json, r'assetId')!, + key: mapValueOfType(json, r'key')!, + updatedAt: mapDateTime(json, r'updatedAt', r'')!, + value: mapValueOfType(json, r'value')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetMetadataBulkResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetMetadataBulkResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMetadataBulkResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetMetadataBulkResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'key', + 'updatedAt', + 'value', + }; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_bulk_upsert_dto.dart b/mobile/openapi/lib/model/asset_metadata_bulk_upsert_dto.dart new file mode 100644 index 0000000000..fe9d9ed251 --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_bulk_upsert_dto.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetMetadataBulkUpsertDto { + /// Returns a new [AssetMetadataBulkUpsertDto] instance. + AssetMetadataBulkUpsertDto({ + this.items = const [], + }); + + List items; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMetadataBulkUpsertDto && + _deepEquality.equals(other.items, items); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (items.hashCode); + + @override + String toString() => 'AssetMetadataBulkUpsertDto[items=$items]'; + + Map toJson() { + final json = {}; + json[r'items'] = this.items; + return json; + } + + /// Returns a new [AssetMetadataBulkUpsertDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMetadataBulkUpsertDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMetadataBulkUpsertDto"); + if (value is Map) { + final json = value.cast(); + + return AssetMetadataBulkUpsertDto( + items: AssetMetadataBulkUpsertItemDto.listFromJson(json[r'items']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetMetadataBulkUpsertDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetMetadataBulkUpsertDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMetadataBulkUpsertDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetMetadataBulkUpsertDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'items', + }; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_bulk_upsert_item_dto.dart b/mobile/openapi/lib/model/asset_metadata_bulk_upsert_item_dto.dart new file mode 100644 index 0000000000..25a219537e --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_bulk_upsert_item_dto.dart @@ -0,0 +1,115 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetMetadataBulkUpsertItemDto { + /// Returns a new [AssetMetadataBulkUpsertItemDto] instance. + AssetMetadataBulkUpsertItemDto({ + required this.assetId, + required this.key, + required this.value, + }); + + String assetId; + + String key; + + Object value; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMetadataBulkUpsertItemDto && + other.assetId == assetId && + other.key == key && + other.value == value; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (key.hashCode) + + (value.hashCode); + + @override + String toString() => 'AssetMetadataBulkUpsertItemDto[assetId=$assetId, key=$key, value=$value]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + json[r'key'] = this.key; + json[r'value'] = this.value; + return json; + } + + /// Returns a new [AssetMetadataBulkUpsertItemDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMetadataBulkUpsertItemDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMetadataBulkUpsertItemDto"); + if (value is Map) { + final json = value.cast(); + + return AssetMetadataBulkUpsertItemDto( + assetId: mapValueOfType(json, r'assetId')!, + key: mapValueOfType(json, r'key')!, + value: mapValueOfType(json, r'value')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetMetadataBulkUpsertItemDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetMetadataBulkUpsertItemDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMetadataBulkUpsertItemDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetMetadataBulkUpsertItemDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'key', + 'value', + }; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_response_dto.dart b/mobile/openapi/lib/model/asset_metadata_response_dto.dart index af5769b9bb..cccf42ae87 100644 --- a/mobile/openapi/lib/model/asset_metadata_response_dto.dart +++ b/mobile/openapi/lib/model/asset_metadata_response_dto.dart @@ -18,7 +18,7 @@ class AssetMetadataResponseDto { required this.value, }); - AssetMetadataKey key; + String key; DateTime updatedAt; @@ -57,7 +57,7 @@ class AssetMetadataResponseDto { final json = value.cast(); return AssetMetadataResponseDto( - key: AssetMetadataKey.fromJson(json[r'key'])!, + key: mapValueOfType(json, r'key')!, updatedAt: mapDateTime(json, r'updatedAt', r'')!, value: mapValueOfType(json, r'value')!, ); diff --git a/mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart b/mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart index 4b7e6579a1..3d247f8572 100644 --- a/mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart +++ b/mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart @@ -17,7 +17,7 @@ class AssetMetadataUpsertItemDto { required this.value, }); - AssetMetadataKey key; + String key; Object value; @@ -51,7 +51,7 @@ class AssetMetadataUpsertItemDto { final json = value.cast(); return AssetMetadataUpsertItemDto( - key: AssetMetadataKey.fromJson(json[r'key'])!, + key: mapValueOfType(json, r'key')!, value: mapValueOfType(json, r'value')!, ); } diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 8d49986359..c9581b19dd 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -23,6 +23,7 @@ class AssetResponseDto { required this.fileCreatedAt, required this.fileModifiedAt, required this.hasMetadata, + required this.height, required this.id, required this.isArchived, required this.isFavorite, @@ -45,6 +46,7 @@ class AssetResponseDto { this.unassignedFaces = const [], required this.updatedAt, required this.visibility, + required this.width, }); /// base64 encoded sha1 hash @@ -77,6 +79,8 @@ class AssetResponseDto { bool hasMetadata; + num? height; + String id; bool isArchived; @@ -141,6 +145,8 @@ class AssetResponseDto { AssetVisibility visibility; + num? width; + @override bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto && other.checksum == checksum && @@ -153,6 +159,7 @@ class AssetResponseDto { other.fileCreatedAt == fileCreatedAt && other.fileModifiedAt == fileModifiedAt && other.hasMetadata == hasMetadata && + other.height == height && other.id == id && other.isArchived == isArchived && other.isFavorite == isFavorite && @@ -174,7 +181,8 @@ class AssetResponseDto { other.type == type && _deepEquality.equals(other.unassignedFaces, unassignedFaces) && other.updatedAt == updatedAt && - other.visibility == visibility; + other.visibility == visibility && + other.width == width; @override int get hashCode => @@ -189,6 +197,7 @@ class AssetResponseDto { (fileCreatedAt.hashCode) + (fileModifiedAt.hashCode) + (hasMetadata.hashCode) + + (height == null ? 0 : height!.hashCode) + (id.hashCode) + (isArchived.hashCode) + (isFavorite.hashCode) + @@ -210,10 +219,11 @@ class AssetResponseDto { (type.hashCode) + (unassignedFaces.hashCode) + (updatedAt.hashCode) + - (visibility.hashCode); + (visibility.hashCode) + + (width == null ? 0 : width!.hashCode); @override - String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility]'; + String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, height=$height, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility, width=$width]'; Map toJson() { final json = {}; @@ -235,6 +245,11 @@ class AssetResponseDto { json[r'fileCreatedAt'] = this.fileCreatedAt.toUtc().toIso8601String(); json[r'fileModifiedAt'] = this.fileModifiedAt.toUtc().toIso8601String(); json[r'hasMetadata'] = this.hasMetadata; + if (this.height != null) { + json[r'height'] = this.height; + } else { + // json[r'height'] = null; + } json[r'id'] = this.id; json[r'isArchived'] = this.isArchived; json[r'isFavorite'] = this.isFavorite; @@ -285,6 +300,11 @@ class AssetResponseDto { json[r'unassignedFaces'] = this.unassignedFaces; json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); json[r'visibility'] = this.visibility; + if (this.width != null) { + json[r'width'] = this.width; + } else { + // json[r'width'] = null; + } return json; } @@ -307,6 +327,9 @@ class AssetResponseDto { fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!, fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!, hasMetadata: mapValueOfType(json, r'hasMetadata')!, + height: json[r'height'] == null + ? null + : num.parse('${json[r'height']}'), id: mapValueOfType(json, r'id')!, isArchived: mapValueOfType(json, r'isArchived')!, isFavorite: mapValueOfType(json, r'isFavorite')!, @@ -329,6 +352,9 @@ class AssetResponseDto { unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']), updatedAt: mapDateTime(json, r'updatedAt', r'')!, visibility: AssetVisibility.fromJson(json[r'visibility'])!, + width: json[r'width'] == null + ? null + : num.parse('${json[r'width']}'), ); } return null; @@ -384,6 +410,7 @@ class AssetResponseDto { 'fileCreatedAt', 'fileModifiedAt', 'hasMetadata', + 'height', 'id', 'isArchived', 'isFavorite', @@ -397,6 +424,7 @@ class AssetResponseDto { 'type', 'updatedAt', 'visibility', + 'width', }; } diff --git a/mobile/openapi/lib/model/crop_parameters.dart b/mobile/openapi/lib/model/crop_parameters.dart new file mode 100644 index 0000000000..8c5b884596 --- /dev/null +++ b/mobile/openapi/lib/model/crop_parameters.dart @@ -0,0 +1,135 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class CropParameters { + /// Returns a new [CropParameters] instance. + CropParameters({ + required this.height, + required this.width, + required this.x, + required this.y, + }); + + /// Height of the crop + /// + /// Minimum value: 1 + num height; + + /// Width of the crop + /// + /// Minimum value: 1 + num width; + + /// Top-Left X coordinate of crop + /// + /// Minimum value: 0 + num x; + + /// Top-Left Y coordinate of crop + /// + /// Minimum value: 0 + num y; + + @override + bool operator ==(Object other) => identical(this, other) || other is CropParameters && + other.height == height && + other.width == width && + other.x == x && + other.y == y; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (height.hashCode) + + (width.hashCode) + + (x.hashCode) + + (y.hashCode); + + @override + String toString() => 'CropParameters[height=$height, width=$width, x=$x, y=$y]'; + + Map toJson() { + final json = {}; + json[r'height'] = this.height; + json[r'width'] = this.width; + json[r'x'] = this.x; + json[r'y'] = this.y; + return json; + } + + /// Returns a new [CropParameters] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static CropParameters? fromJson(dynamic value) { + upgradeDto(value, "CropParameters"); + if (value is Map) { + final json = value.cast(); + + return CropParameters( + height: num.parse('${json[r'height']}'), + width: num.parse('${json[r'width']}'), + x: num.parse('${json[r'x']}'), + y: num.parse('${json[r'y']}'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = CropParameters.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = CropParameters.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of CropParameters-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = CropParameters.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'height', + 'width', + 'x', + 'y', + }; +} + diff --git a/mobile/openapi/lib/model/job_name.dart b/mobile/openapi/lib/model/job_name.dart index 038a17a8e6..b027c92114 100644 --- a/mobile/openapi/lib/model/job_name.dart +++ b/mobile/openapi/lib/model/job_name.dart @@ -29,6 +29,7 @@ class JobName { static const assetDetectFaces = JobName._(r'AssetDetectFaces'); static const assetDetectDuplicatesQueueAll = JobName._(r'AssetDetectDuplicatesQueueAll'); static const assetDetectDuplicates = JobName._(r'AssetDetectDuplicates'); + static const assetEditThumbnailGeneration = JobName._(r'AssetEditThumbnailGeneration'); static const assetEncodeVideoQueueAll = JobName._(r'AssetEncodeVideoQueueAll'); static const assetEncodeVideo = JobName._(r'AssetEncodeVideo'); static const assetEmptyTrash = JobName._(r'AssetEmptyTrash'); @@ -87,6 +88,7 @@ class JobName { assetDetectFaces, assetDetectDuplicatesQueueAll, assetDetectDuplicates, + assetEditThumbnailGeneration, assetEncodeVideoQueueAll, assetEncodeVideo, assetEmptyTrash, @@ -180,6 +182,7 @@ class JobNameTypeTransformer { case r'AssetDetectFaces': return JobName.assetDetectFaces; case r'AssetDetectDuplicatesQueueAll': return JobName.assetDetectDuplicatesQueueAll; case r'AssetDetectDuplicates': return JobName.assetDetectDuplicates; + case r'AssetEditThumbnailGeneration': return JobName.assetEditThumbnailGeneration; case r'AssetEncodeVideoQueueAll': return JobName.assetEncodeVideoQueueAll; case r'AssetEncodeVideo': return JobName.assetEncodeVideo; case r'AssetEmptyTrash': return JobName.assetEmptyTrash; diff --git a/mobile/openapi/lib/model/asset_metadata_key.dart b/mobile/openapi/lib/model/mirror_axis.dart similarity index 52% rename from mobile/openapi/lib/model/asset_metadata_key.dart rename to mobile/openapi/lib/model/mirror_axis.dart index 70186cd41c..4deeeb047c 100644 --- a/mobile/openapi/lib/model/asset_metadata_key.dart +++ b/mobile/openapi/lib/model/mirror_axis.dart @@ -10,10 +10,10 @@ part of openapi.api; - -class AssetMetadataKey { +/// Axis to mirror along +class MirrorAxis { /// Instantiate a new enum with the provided [value]. - const AssetMetadataKey._(this.value); + const MirrorAxis._(this.value); /// The underlying value of this enum member. final String value; @@ -23,20 +23,22 @@ class AssetMetadataKey { String toJson() => value; - static const mobileApp = AssetMetadataKey._(r'mobile-app'); + static const horizontal = MirrorAxis._(r'horizontal'); + static const vertical = MirrorAxis._(r'vertical'); - /// List of all possible values in this [enum][AssetMetadataKey]. - static const values = [ - mobileApp, + /// List of all possible values in this [enum][MirrorAxis]. + static const values = [ + horizontal, + vertical, ]; - static AssetMetadataKey? fromJson(dynamic value) => AssetMetadataKeyTypeTransformer().decode(value); + static MirrorAxis? fromJson(dynamic value) => MirrorAxisTypeTransformer().decode(value); - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = AssetMetadataKey.fromJson(row); + final value = MirrorAxis.fromJson(row); if (value != null) { result.add(value); } @@ -46,16 +48,16 @@ class AssetMetadataKey { } } -/// Transformation class that can [encode] an instance of [AssetMetadataKey] to String, -/// and [decode] dynamic data back to [AssetMetadataKey]. -class AssetMetadataKeyTypeTransformer { - factory AssetMetadataKeyTypeTransformer() => _instance ??= const AssetMetadataKeyTypeTransformer._(); +/// Transformation class that can [encode] an instance of [MirrorAxis] to String, +/// and [decode] dynamic data back to [MirrorAxis]. +class MirrorAxisTypeTransformer { + factory MirrorAxisTypeTransformer() => _instance ??= const MirrorAxisTypeTransformer._(); - const AssetMetadataKeyTypeTransformer._(); + const MirrorAxisTypeTransformer._(); - String encode(AssetMetadataKey data) => data.value; + String encode(MirrorAxis data) => data.value; - /// Decodes a [dynamic value][data] to a AssetMetadataKey. + /// Decodes a [dynamic value][data] to a MirrorAxis. /// /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] @@ -63,10 +65,11 @@ class AssetMetadataKeyTypeTransformer { /// /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, /// and users are still using an old app with the old code. - AssetMetadataKey? decode(dynamic data, {bool allowNull = true}) { + MirrorAxis? decode(dynamic data, {bool allowNull = true}) { if (data != null) { switch (data) { - case r'mobile-app': return AssetMetadataKey.mobileApp; + case r'horizontal': return MirrorAxis.horizontal; + case r'vertical': return MirrorAxis.vertical; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); @@ -76,7 +79,7 @@ class AssetMetadataKeyTypeTransformer { return null; } - /// Singleton [AssetMetadataKeyTypeTransformer] instance. - static AssetMetadataKeyTypeTransformer? _instance; + /// Singleton [MirrorAxisTypeTransformer] instance. + static MirrorAxisTypeTransformer? _instance; } diff --git a/mobile/openapi/lib/model/mirror_parameters.dart b/mobile/openapi/lib/model/mirror_parameters.dart new file mode 100644 index 0000000000..e8b8db685b --- /dev/null +++ b/mobile/openapi/lib/model/mirror_parameters.dart @@ -0,0 +1,100 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class MirrorParameters { + /// Returns a new [MirrorParameters] instance. + MirrorParameters({ + required this.axis, + }); + + /// Axis to mirror along + MirrorAxis axis; + + @override + bool operator ==(Object other) => identical(this, other) || other is MirrorParameters && + other.axis == axis; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (axis.hashCode); + + @override + String toString() => 'MirrorParameters[axis=$axis]'; + + Map toJson() { + final json = {}; + json[r'axis'] = this.axis; + return json; + } + + /// Returns a new [MirrorParameters] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static MirrorParameters? fromJson(dynamic value) { + upgradeDto(value, "MirrorParameters"); + if (value is Map) { + final json = value.cast(); + + return MirrorParameters( + axis: MirrorAxis.fromJson(json[r'axis'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = MirrorParameters.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = MirrorParameters.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of MirrorParameters-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = MirrorParameters.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'axis', + }; +} + diff --git a/mobile/openapi/lib/model/queue_name.dart b/mobile/openapi/lib/model/queue_name.dart index bcc4159fce..d94304d0d3 100644 --- a/mobile/openapi/lib/model/queue_name.dart +++ b/mobile/openapi/lib/model/queue_name.dart @@ -40,6 +40,7 @@ class QueueName { static const backupDatabase = QueueName._(r'backupDatabase'); static const ocr = QueueName._(r'ocr'); static const workflow = QueueName._(r'workflow'); + static const editor = QueueName._(r'editor'); /// List of all possible values in this [enum][QueueName]. static const values = [ @@ -60,6 +61,7 @@ class QueueName { backupDatabase, ocr, workflow, + editor, ]; static QueueName? fromJson(dynamic value) => QueueNameTypeTransformer().decode(value); @@ -115,6 +117,7 @@ class QueueNameTypeTransformer { case r'backupDatabase': return QueueName.backupDatabase; case r'ocr': return QueueName.ocr; case r'workflow': return QueueName.workflow; + case r'editor': return QueueName.editor; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/mobile/openapi/lib/model/queues_response_legacy_dto.dart b/mobile/openapi/lib/model/queues_response_legacy_dto.dart index 4aab6d863b..c7bc23cb4d 100644 --- a/mobile/openapi/lib/model/queues_response_legacy_dto.dart +++ b/mobile/openapi/lib/model/queues_response_legacy_dto.dart @@ -16,6 +16,7 @@ class QueuesResponseLegacyDto { required this.backgroundTask, required this.backupDatabase, required this.duplicateDetection, + required this.editor, required this.faceDetection, required this.facialRecognition, required this.library_, @@ -38,6 +39,8 @@ class QueuesResponseLegacyDto { QueueResponseLegacyDto duplicateDetection; + QueueResponseLegacyDto editor; + QueueResponseLegacyDto faceDetection; QueueResponseLegacyDto facialRecognition; @@ -71,6 +74,7 @@ class QueuesResponseLegacyDto { other.backgroundTask == backgroundTask && other.backupDatabase == backupDatabase && other.duplicateDetection == duplicateDetection && + other.editor == editor && other.faceDetection == faceDetection && other.facialRecognition == facialRecognition && other.library_ == library_ && @@ -92,6 +96,7 @@ class QueuesResponseLegacyDto { (backgroundTask.hashCode) + (backupDatabase.hashCode) + (duplicateDetection.hashCode) + + (editor.hashCode) + (faceDetection.hashCode) + (facialRecognition.hashCode) + (library_.hashCode) + @@ -108,13 +113,14 @@ class QueuesResponseLegacyDto { (workflow.hashCode); @override - String toString() => 'QueuesResponseLegacyDto[backgroundTask=$backgroundTask, backupDatabase=$backupDatabase, duplicateDetection=$duplicateDetection, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion, workflow=$workflow]'; + String toString() => 'QueuesResponseLegacyDto[backgroundTask=$backgroundTask, backupDatabase=$backupDatabase, duplicateDetection=$duplicateDetection, editor=$editor, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion, workflow=$workflow]'; Map toJson() { final json = {}; json[r'backgroundTask'] = this.backgroundTask; json[r'backupDatabase'] = this.backupDatabase; json[r'duplicateDetection'] = this.duplicateDetection; + json[r'editor'] = this.editor; json[r'faceDetection'] = this.faceDetection; json[r'facialRecognition'] = this.facialRecognition; json[r'library'] = this.library_; @@ -144,6 +150,7 @@ class QueuesResponseLegacyDto { backgroundTask: QueueResponseLegacyDto.fromJson(json[r'backgroundTask'])!, backupDatabase: QueueResponseLegacyDto.fromJson(json[r'backupDatabase'])!, duplicateDetection: QueueResponseLegacyDto.fromJson(json[r'duplicateDetection'])!, + editor: QueueResponseLegacyDto.fromJson(json[r'editor'])!, faceDetection: QueueResponseLegacyDto.fromJson(json[r'faceDetection'])!, facialRecognition: QueueResponseLegacyDto.fromJson(json[r'facialRecognition'])!, library_: QueueResponseLegacyDto.fromJson(json[r'library'])!, @@ -208,6 +215,7 @@ class QueuesResponseLegacyDto { 'backgroundTask', 'backupDatabase', 'duplicateDetection', + 'editor', 'faceDetection', 'facialRecognition', 'library', diff --git a/mobile/openapi/lib/model/rotate_parameters.dart b/mobile/openapi/lib/model/rotate_parameters.dart new file mode 100644 index 0000000000..33609e83e5 --- /dev/null +++ b/mobile/openapi/lib/model/rotate_parameters.dart @@ -0,0 +1,100 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class RotateParameters { + /// Returns a new [RotateParameters] instance. + RotateParameters({ + required this.angle, + }); + + /// Rotation angle in degrees + num angle; + + @override + bool operator ==(Object other) => identical(this, other) || other is RotateParameters && + other.angle == angle; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (angle.hashCode); + + @override + String toString() => 'RotateParameters[angle=$angle]'; + + Map toJson() { + final json = {}; + json[r'angle'] = this.angle; + return json; + } + + /// Returns a new [RotateParameters] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static RotateParameters? fromJson(dynamic value) { + upgradeDto(value, "RotateParameters"); + if (value is Map) { + final json = value.cast(); + + return RotateParameters( + angle: num.parse('${json[r'angle']}'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = RotateParameters.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = RotateParameters.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of RotateParameters-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = RotateParameters.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'angle', + }; +} + diff --git a/mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart b/mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart index c9a7ef4670..cf67b68dd2 100644 --- a/mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart +++ b/mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart @@ -19,7 +19,7 @@ class SyncAssetMetadataDeleteV1 { String assetId; - AssetMetadataKey key; + String key; @override bool operator ==(Object other) => identical(this, other) || other is SyncAssetMetadataDeleteV1 && @@ -52,7 +52,7 @@ class SyncAssetMetadataDeleteV1 { return SyncAssetMetadataDeleteV1( assetId: mapValueOfType(json, r'assetId')!, - key: AssetMetadataKey.fromJson(json[r'key'])!, + key: mapValueOfType(json, r'key')!, ); } return null; diff --git a/mobile/openapi/lib/model/sync_asset_metadata_v1.dart b/mobile/openapi/lib/model/sync_asset_metadata_v1.dart index 720fcef947..4fa6ed84ed 100644 --- a/mobile/openapi/lib/model/sync_asset_metadata_v1.dart +++ b/mobile/openapi/lib/model/sync_asset_metadata_v1.dart @@ -20,7 +20,7 @@ class SyncAssetMetadataV1 { String assetId; - AssetMetadataKey key; + String key; Object value; @@ -58,7 +58,7 @@ class SyncAssetMetadataV1 { return SyncAssetMetadataV1( assetId: mapValueOfType(json, r'assetId')!, - key: AssetMetadataKey.fromJson(json[r'key'])!, + key: mapValueOfType(json, r'key')!, value: mapValueOfType(json, r'value')!, ); } diff --git a/mobile/openapi/lib/model/sync_asset_v1.dart b/mobile/openapi/lib/model/sync_asset_v1.dart index f0d5097ea4..a2c89eb5c1 100644 --- a/mobile/openapi/lib/model/sync_asset_v1.dart +++ b/mobile/openapi/lib/model/sync_asset_v1.dart @@ -18,6 +18,7 @@ class SyncAssetV1 { required this.duration, required this.fileCreatedAt, required this.fileModifiedAt, + required this.height, required this.id, required this.isFavorite, required this.libraryId, @@ -29,6 +30,7 @@ class SyncAssetV1 { required this.thumbhash, required this.type, required this.visibility, + required this.width, }); String checksum; @@ -41,6 +43,8 @@ class SyncAssetV1 { DateTime? fileModifiedAt; + int? height; + String id; bool isFavorite; @@ -63,6 +67,8 @@ class SyncAssetV1 { AssetVisibility visibility; + int? width; + @override bool operator ==(Object other) => identical(this, other) || other is SyncAssetV1 && other.checksum == checksum && @@ -70,6 +76,7 @@ class SyncAssetV1 { other.duration == duration && other.fileCreatedAt == fileCreatedAt && other.fileModifiedAt == fileModifiedAt && + other.height == height && other.id == id && other.isFavorite == isFavorite && other.libraryId == libraryId && @@ -80,7 +87,8 @@ class SyncAssetV1 { other.stackId == stackId && other.thumbhash == thumbhash && other.type == type && - other.visibility == visibility; + other.visibility == visibility && + other.width == width; @override int get hashCode => @@ -90,6 +98,7 @@ class SyncAssetV1 { (duration == null ? 0 : duration!.hashCode) + (fileCreatedAt == null ? 0 : fileCreatedAt!.hashCode) + (fileModifiedAt == null ? 0 : fileModifiedAt!.hashCode) + + (height == null ? 0 : height!.hashCode) + (id.hashCode) + (isFavorite.hashCode) + (libraryId == null ? 0 : libraryId!.hashCode) + @@ -100,10 +109,11 @@ class SyncAssetV1 { (stackId == null ? 0 : stackId!.hashCode) + (thumbhash == null ? 0 : thumbhash!.hashCode) + (type.hashCode) + - (visibility.hashCode); + (visibility.hashCode) + + (width == null ? 0 : width!.hashCode); @override - String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility]'; + String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, height=$height, id=$id, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility, width=$width]'; Map toJson() { final json = {}; @@ -127,6 +137,11 @@ class SyncAssetV1 { json[r'fileModifiedAt'] = this.fileModifiedAt!.toUtc().toIso8601String(); } else { // json[r'fileModifiedAt'] = null; + } + if (this.height != null) { + json[r'height'] = this.height; + } else { + // json[r'height'] = null; } json[r'id'] = this.id; json[r'isFavorite'] = this.isFavorite; @@ -159,6 +174,11 @@ class SyncAssetV1 { } json[r'type'] = this.type; json[r'visibility'] = this.visibility; + if (this.width != null) { + json[r'width'] = this.width; + } else { + // json[r'width'] = null; + } return json; } @@ -176,6 +196,7 @@ class SyncAssetV1 { duration: mapValueOfType(json, r'duration'), fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r''), fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r''), + height: mapValueOfType(json, r'height'), id: mapValueOfType(json, r'id')!, isFavorite: mapValueOfType(json, r'isFavorite')!, libraryId: mapValueOfType(json, r'libraryId'), @@ -187,6 +208,7 @@ class SyncAssetV1 { thumbhash: mapValueOfType(json, r'thumbhash'), type: AssetTypeEnum.fromJson(json[r'type'])!, visibility: AssetVisibility.fromJson(json[r'visibility'])!, + width: mapValueOfType(json, r'width'), ); } return null; @@ -239,6 +261,7 @@ class SyncAssetV1 { 'duration', 'fileCreatedAt', 'fileModifiedAt', + 'height', 'id', 'isFavorite', 'libraryId', @@ -250,6 +273,7 @@ class SyncAssetV1 { 'thumbhash', 'type', 'visibility', + 'width', }; } diff --git a/mobile/openapi/lib/model/system_config_job_dto.dart b/mobile/openapi/lib/model/system_config_job_dto.dart index 461420b3e3..d54db6809f 100644 --- a/mobile/openapi/lib/model/system_config_job_dto.dart +++ b/mobile/openapi/lib/model/system_config_job_dto.dart @@ -14,6 +14,7 @@ class SystemConfigJobDto { /// Returns a new [SystemConfigJobDto] instance. SystemConfigJobDto({ required this.backgroundTask, + required this.editor, required this.faceDetection, required this.library_, required this.metadataExtraction, @@ -30,6 +31,8 @@ class SystemConfigJobDto { JobSettingsDto backgroundTask; + JobSettingsDto editor; + JobSettingsDto faceDetection; JobSettingsDto library_; @@ -57,6 +60,7 @@ class SystemConfigJobDto { @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigJobDto && other.backgroundTask == backgroundTask && + other.editor == editor && other.faceDetection == faceDetection && other.library_ == library_ && other.metadataExtraction == metadataExtraction && @@ -74,6 +78,7 @@ class SystemConfigJobDto { int get hashCode => // ignore: unnecessary_parenthesis (backgroundTask.hashCode) + + (editor.hashCode) + (faceDetection.hashCode) + (library_.hashCode) + (metadataExtraction.hashCode) + @@ -88,11 +93,12 @@ class SystemConfigJobDto { (workflow.hashCode); @override - String toString() => 'SystemConfigJobDto[backgroundTask=$backgroundTask, faceDetection=$faceDetection, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion, workflow=$workflow]'; + String toString() => 'SystemConfigJobDto[backgroundTask=$backgroundTask, editor=$editor, faceDetection=$faceDetection, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion, workflow=$workflow]'; Map toJson() { final json = {}; json[r'backgroundTask'] = this.backgroundTask; + json[r'editor'] = this.editor; json[r'faceDetection'] = this.faceDetection; json[r'library'] = this.library_; json[r'metadataExtraction'] = this.metadataExtraction; @@ -118,6 +124,7 @@ class SystemConfigJobDto { return SystemConfigJobDto( backgroundTask: JobSettingsDto.fromJson(json[r'backgroundTask'])!, + editor: JobSettingsDto.fromJson(json[r'editor'])!, faceDetection: JobSettingsDto.fromJson(json[r'faceDetection'])!, library_: JobSettingsDto.fromJson(json[r'library'])!, metadataExtraction: JobSettingsDto.fromJson(json[r'metadataExtraction'])!, @@ -178,6 +185,7 @@ class SystemConfigJobDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'backgroundTask', + 'editor', 'faceDetection', 'library', 'metadataExtraction', diff --git a/mobile/packages/ui/lib/immich_ui.dart b/mobile/packages/ui/lib/immich_ui.dart index 2417149f76..9f2a886ab3 100644 --- a/mobile/packages/ui/lib/immich_ui.dart +++ b/mobile/packages/ui/lib/immich_ui.dart @@ -1,3 +1,10 @@ -export 'src/buttons/close_button.dart'; -export 'src/buttons/icon_button.dart'; +export 'src/components/close_button.dart'; +export 'src/components/form.dart'; +export 'src/components/icon_button.dart'; +export 'src/components/password_input.dart'; +export 'src/components/text_button.dart'; +export 'src/components/text_input.dart'; +export 'src/constants.dart'; +export 'src/theme.dart'; +export 'src/translation.dart'; export 'src/types.dart'; diff --git a/mobile/packages/ui/lib/src/buttons/close_button.dart b/mobile/packages/ui/lib/src/components/close_button.dart similarity index 75% rename from mobile/packages/ui/lib/src/buttons/close_button.dart rename to mobile/packages/ui/lib/src/components/close_button.dart index c8c5d62a12..9308fdaadb 100644 --- a/mobile/packages/ui/lib/src/buttons/close_button.dart +++ b/mobile/packages/ui/lib/src/components/close_button.dart @@ -1,15 +1,16 @@ import 'package:flutter/material.dart'; -import 'package:immich_ui/src/buttons/icon_button.dart'; import 'package:immich_ui/src/types.dart'; +import 'icon_button.dart'; + class ImmichCloseButton extends StatelessWidget { - final VoidCallback? onTap; + final VoidCallback? onPressed; final ImmichVariant variant; final ImmichColor color; const ImmichCloseButton({ super.key, - this.onTap, + this.onPressed, this.color = ImmichColor.primary, this.variant = ImmichVariant.ghost, }); @@ -20,6 +21,6 @@ class ImmichCloseButton extends StatelessWidget { icon: Icons.close, color: color, variant: variant, - onTap: onTap ?? () => Navigator.of(context).pop(), + onPressed: onPressed ?? () => Navigator.of(context).pop(), ); } diff --git a/mobile/packages/ui/lib/src/components/form.dart b/mobile/packages/ui/lib/src/components/form.dart new file mode 100644 index 0000000000..9e8c161806 --- /dev/null +++ b/mobile/packages/ui/lib/src/components/form.dart @@ -0,0 +1,98 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:immich_ui/src/internal.dart'; + +class ImmichForm extends StatefulWidget { + final String? submitText; + final IconData? submitIcon; + final FutureOr Function()? onSubmit; + final Widget child; + + const ImmichForm({ + super.key, + this.submitText, + this.submitIcon, + required this.onSubmit, + required this.child, + }); + + @override + State createState() => ImmichFormState(); + + static ImmichFormState of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_ImmichFormScope>(); + if (scope == null) { + throw FlutterError( + 'ImmichForm.of() called with a context that does not contain an ImmichForm.\n' + 'No ImmichForm ancestor could be found starting from the context that was passed to ' + 'ImmichForm.of(). This usually happens when the context provided is ' + 'from a widget above the ImmichForm.\n' + 'The context used was:\n' + '$context', + ); + } + return scope._formState; + } +} + +class ImmichFormState extends State { + final _formKey = GlobalKey(); + bool _isLoading = false; + + FutureOr submit() async { + final isValid = _formKey.currentState?.validate() ?? false; + if (!isValid) { + return; + } + + setState(() { + _isLoading = true; + }); + + try { + await widget.onSubmit?.call(); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final submitText = widget.submitText ?? context.translations.submit; + return _ImmichFormScope( + formState: this, + child: Form( + key: _formKey, + child: Column( + spacing: ImmichSpacing.md, + children: [ + widget.child, + ImmichTextButton( + labelText: submitText, + icon: widget.submitIcon, + variant: ImmichVariant.filled, + loading: _isLoading, + onPressed: submit, + disabled: widget.onSubmit == null, + ), + ], + ), + ), + ); + } +} + +class _ImmichFormScope extends InheritedWidget { + const _ImmichFormScope({required super.child, required ImmichFormState formState}) : _formState = formState; + + final ImmichFormState _formState; + + @override + bool updateShouldNotify(_ImmichFormScope oldWidget) => oldWidget._formState != _formState; +} diff --git a/mobile/packages/ui/lib/src/buttons/icon_button.dart b/mobile/packages/ui/lib/src/components/icon_button.dart similarity index 60% rename from mobile/packages/ui/lib/src/buttons/icon_button.dart rename to mobile/packages/ui/lib/src/components/icon_button.dart index 5c62ee8eda..dc140b71f9 100644 --- a/mobile/packages/ui/lib/src/buttons/icon_button.dart +++ b/mobile/packages/ui/lib/src/components/icon_button.dart @@ -3,42 +3,48 @@ import 'package:immich_ui/src/types.dart'; class ImmichIconButton extends StatelessWidget { final IconData icon; - final VoidCallback onTap; + final VoidCallback onPressed; final ImmichVariant variant; final ImmichColor color; + final bool disabled; const ImmichIconButton({ super.key, required this.icon, - required this.onTap, + required this.onPressed, this.color = ImmichColor.primary, this.variant = ImmichVariant.filled, + this.disabled = false, }); @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final background = switch (variant) { ImmichVariant.filled => switch (color) { - ImmichColor.primary => Theme.of(context).colorScheme.primary, - ImmichColor.secondary => Theme.of(context).colorScheme.secondary, + ImmichColor.primary => colorScheme.primary, + ImmichColor.secondary => colorScheme.secondary, }, ImmichVariant.ghost => Colors.transparent, }; final foreground = switch (variant) { ImmichVariant.filled => switch (color) { - ImmichColor.primary => Theme.of(context).colorScheme.onPrimary, - ImmichColor.secondary => Theme.of(context).colorScheme.onSecondary, + ImmichColor.primary => colorScheme.onPrimary, + ImmichColor.secondary => colorScheme.onSecondary, }, ImmichVariant.ghost => switch (color) { - ImmichColor.primary => Theme.of(context).colorScheme.primary, - ImmichColor.secondary => Theme.of(context).colorScheme.secondary, + ImmichColor.primary => colorScheme.primary, + ImmichColor.secondary => colorScheme.secondary, }, }; + final effectiveOnPressed = disabled ? null : onPressed; + return IconButton( icon: Icon(icon), - onPressed: onTap, + onPressed: effectiveOnPressed, style: IconButton.styleFrom( backgroundColor: background, foregroundColor: foreground, diff --git a/mobile/packages/ui/lib/src/components/password_input.dart b/mobile/packages/ui/lib/src/components/password_input.dart new file mode 100644 index 0000000000..bd5a149354 --- /dev/null +++ b/mobile/packages/ui/lib/src/components/password_input.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/src/components/text_input.dart'; +import 'package:immich_ui/src/internal.dart'; + +class ImmichPasswordInput extends StatefulWidget { + final String? label; + final String? hintText; + final TextEditingController? controller; + final FocusNode? focusNode; + final String? Function(String?)? validator; + final void Function(BuildContext, String)? onSubmit; + final TextInputAction? keyboardAction; + + const ImmichPasswordInput({ + super.key, + this.controller, + this.focusNode, + this.label, + this.hintText, + this.validator, + this.onSubmit, + this.keyboardAction, + }); + + @override + State createState() => _ImmichPasswordInputState(); +} + +class _ImmichPasswordInputState extends State { + bool _visible = false; + + void _toggleVisibility() { + setState(() { + _visible = !_visible; + }); + } + + @override + Widget build(BuildContext context) { + return ImmichTextInput( + key: widget.key, + label: widget.label ?? context.translations.password, + hintText: widget.hintText, + controller: widget.controller, + focusNode: widget.focusNode, + validator: widget.validator, + onSubmit: widget.onSubmit, + keyboardAction: widget.keyboardAction, + obscureText: !_visible, + suffixIcon: IconButton( + onPressed: _toggleVisibility, + icon: Icon(_visible ? Icons.visibility_off_rounded : Icons.visibility_rounded), + ), + autofillHints: [AutofillHints.password], + keyboardType: TextInputType.text, + ); + } +} diff --git a/mobile/packages/ui/lib/src/components/text_button.dart b/mobile/packages/ui/lib/src/components/text_button.dart new file mode 100644 index 0000000000..6dc677aee2 --- /dev/null +++ b/mobile/packages/ui/lib/src/components/text_button.dart @@ -0,0 +1,87 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:immich_ui/src/constants.dart'; +import 'package:immich_ui/src/types.dart'; + +class ImmichTextButton extends StatelessWidget { + final String labelText; + final IconData? icon; + final FutureOr Function() onPressed; + final ImmichVariant variant; + final ImmichColor color; + final bool expanded; + final bool loading; + final bool disabled; + + const ImmichTextButton({ + super.key, + required this.labelText, + this.icon, + required this.onPressed, + this.variant = ImmichVariant.filled, + this.color = ImmichColor.primary, + this.expanded = true, + this.loading = false, + this.disabled = false, + }); + + Widget _buildButton(ImmichVariant variant) { + final Widget? effectiveIcon = loading + ? const SizedBox.square( + dimension: ImmichIconSize.md, + child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.lg), + ) + : icon != null + ? Icon(icon, fontWeight: FontWeight.w600) + : null; + final hasIcon = effectiveIcon != null; + + final label = Text(labelText, style: const TextStyle(fontSize: ImmichTextSize.body, fontWeight: FontWeight.bold)); + final style = ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: ImmichSpacing.md)); + + final effectiveOnPressed = disabled || loading ? null : onPressed; + + switch (variant) { + case ImmichVariant.filled: + if (hasIcon) { + return ElevatedButton.icon( + style: style, + onPressed: effectiveOnPressed, + icon: effectiveIcon, + label: label, + ); + } + + return ElevatedButton( + style: style, + onPressed: effectiveOnPressed, + child: label, + ); + case ImmichVariant.ghost: + if (hasIcon) { + return TextButton.icon( + style: style, + onPressed: effectiveOnPressed, + icon: effectiveIcon, + label: label, + ); + } + + return TextButton( + style: style, + onPressed: effectiveOnPressed, + child: label, + ); + } + } + + @override + Widget build(BuildContext context) { + final button = _buildButton(variant); + if (expanded) { + return SizedBox(width: double.infinity, child: button); + } + return button; + } +} diff --git a/mobile/packages/ui/lib/src/components/text_input.dart b/mobile/packages/ui/lib/src/components/text_input.dart new file mode 100644 index 0000000000..f335df49f4 --- /dev/null +++ b/mobile/packages/ui/lib/src/components/text_input.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +class ImmichTextInput extends StatefulWidget { + final String label; + final String? hintText; + final TextEditingController? controller; + final FocusNode? focusNode; + final String? Function(String?)? validator; + final void Function(BuildContext, String)? onSubmit; + final TextInputType keyboardType; + final TextInputAction? keyboardAction; + final List? autofillHints; + final Widget? suffixIcon; + final bool obscureText; + + const ImmichTextInput({ + super.key, + this.controller, + this.focusNode, + required this.label, + this.hintText, + this.validator, + this.onSubmit, + this.keyboardType = TextInputType.text, + this.keyboardAction, + this.autofillHints, + this.suffixIcon, + this.obscureText = false, + }); + + @override + State createState() => _ImmichTextInputState(); +} + +class _ImmichTextInputState extends State { + late final FocusNode _focusNode; + String? _error; + + @override + void initState() { + super.initState(); + _focusNode = widget.focusNode ?? FocusNode(); + } + + @override + void dispose() { + if (widget.focusNode == null) { + _focusNode.dispose(); + } + super.dispose(); + } + + String? _validateInput(String? value) { + setState(() { + _error = widget.validator?.call(value); + }); + return null; + } + + bool get _hasError => _error != null && _error!.isNotEmpty; + + @override + Widget build(BuildContext context) { + final themeData = Theme.of(context); + + return TextFormField( + controller: widget.controller, + focusNode: _focusNode, + decoration: InputDecoration( + hintText: widget.hintText, + labelText: widget.label, + labelStyle: themeData.inputDecorationTheme.labelStyle?.copyWith( + color: _hasError ? themeData.colorScheme.error : null, + ), + errorText: _error, + suffixIcon: widget.suffixIcon, + ), + obscureText: widget.obscureText, + validator: _validateInput, + keyboardType: widget.keyboardType, + textInputAction: widget.keyboardAction, + autofillHints: widget.autofillHints, + onTap: () => setState(() => _error = null), + onTapOutside: (_) => _focusNode.unfocus(), + onFieldSubmitted: (value) => widget.onSubmit?.call(context, value), + ); + } +} diff --git a/mobile/packages/ui/lib/src/constants.dart b/mobile/packages/ui/lib/src/constants.dart new file mode 100644 index 0000000000..96122c9b36 --- /dev/null +++ b/mobile/packages/ui/lib/src/constants.dart @@ -0,0 +1,199 @@ +/// Spacing constants for gaps between widgets +abstract class ImmichSpacing { + const ImmichSpacing._(); + + /// Extra small spacing: 4.0 + static const double xs = 4.0; + + /// Small spacing: 8.0 + static const double sm = 8.0; + + /// Medium spacing (default): 12.0 + static const double md = 12.0; + + /// Large spacing: 16.0 + static const double lg = 16.0; + + /// Extra large spacing: 24.0 + static const double xl = 24.0; + + /// Extra extra large spacing: 32.0 + static const double xxl = 32.0; + + /// Extra extra extra large spacing: 48.0 + static const double xxxl = 48.0; +} + +/// Border radius constants for consistent rounded corners +abstract class ImmichRadius { + const ImmichRadius._(); + + /// No radius: 0.0 + static const double none = 0.0; + + /// Extra small radius: 4.0 + static const double xs = 4.0; + + /// Small radius: 8.0 + static const double sm = 8.0; + + /// Medium radius (default): 12.0 + static const double md = 12.0; + + /// Large radius: 16.0 + static const double lg = 16.0; + + /// Extra large radius: 20.0 + static const double xl = 20.0; + + /// Extra extra large radius: 24.0 + static const double xxl = 24.0; + + /// Full circular radius: infinity + static const double full = double.infinity; +} + +/// Icon size constants for consistent icon sizing +abstract class ImmichIconSize { + const ImmichIconSize._(); + + /// Extra small icon: 16.0 + static const double xs = 16.0; + + /// Small icon: 20.0 + static const double sm = 20.0; + + /// Medium icon (default): 24.0 + static const double md = 24.0; + + /// Large icon: 32.0 + static const double lg = 32.0; + + /// Extra large icon: 40.0 + static const double xl = 40.0; + + /// Extra extra large icon: 48.0 + static const double xxl = 48.0; +} + +/// Animation duration constants for consistent timing +abstract class ImmichDuration { + const ImmichDuration._(); + + /// Extra fast: 100ms + static const Duration extraFast = Duration(milliseconds: 100); + + /// Fast: 150ms + static const Duration fast = Duration(milliseconds: 150); + + /// Normal: 200ms + static const Duration normal = Duration(milliseconds: 200); + + /// Moderate: 300ms + static const Duration moderate = Duration(milliseconds: 300); + + /// Slow: 500ms + static const Duration slow = Duration(milliseconds: 500); + + /// Extra slow: 700ms + static const Duration extraSlow = Duration(milliseconds: 700); +} + +/// Elevation constants for consistent shadows and depth +abstract class ImmichElevation { + const ImmichElevation._(); + + /// No elevation: 0.0 + static const double none = 0.0; + + /// Extra small elevation: 1.0 + static const double xs = 1.0; + + /// Small elevation: 2.0 + static const double sm = 2.0; + + /// Medium elevation: 4.0 + static const double md = 4.0; + + /// Large elevation: 8.0 + static const double lg = 8.0; + + /// Extra large elevation: 12.0 + static const double xl = 12.0; + + /// Extra extra large elevation: 16.0 + static const double xxl = 16.0; +} + +/// Border width constants (similar to Tailwind's border-* scale) +abstract class ImmichBorderWidth { + const ImmichBorderWidth._(); + + /// No border: 0.0 + static const double none = 0.0; + + /// Hairline border: 0.5 + static const double hairline = 0.5; + + /// Default border: 1.0 (border) + static const double base = 1.0; + + /// Medium border: 2.0 (border-2) + static const double md = 2.0; + + /// Large border: 3.0 (border-4) + static const double lg = 3.0; + + /// Extra large border: 4.0 + static const double xl = 4.0; +} + +/// Text size constants with semantic HTML-like naming +/// These follow a type scale for harmonious text hierarchy +abstract class ImmichTextSize { + const ImmichTextSize._(); + + /// Caption text: 10.0 + /// Use for: Tiny labels, legal text, metadata, timestamps + static const double caption = 10.0; + + /// Label text: 12.0 + /// Use for: Form labels, secondary text, helper text + static const double label = 12.0; + + /// Body text: 14.0 (default) + /// Use for: Main body text, paragraphs, default UI text + static const double body = 14.0; + + /// Body emphasized: 16.0 + /// Use for: Emphasized body text, button labels, tabs + static const double bodyLarge = 16.0; + + /// Heading 6: 18.0 (smallest heading) + /// Use for: Subtitles, card titles, section headers + static const double h6 = 18.0; + + /// Heading 5: 20.0 + /// Use for: Small headings, prominent labels + static const double h5 = 20.0; + + /// Heading 4: 24.0 + /// Use for: Page titles, dialog titles + static const double h4 = 24.0; + + /// Heading 3: 30.0 + /// Use for: Section headings, large headings + static const double h3 = 30.0; + + /// Heading 2: 36.0 + /// Use for: Major section headings + static const double h2 = 36.0; + + /// Heading 1: 48.0 (largest heading) + /// Use for: Page hero headings, main titles + static const double h1 = 48.0; + + /// Display text: 60.0 + /// Use for: Hero numbers, splash screens, extra large display + static const double display = 60.0; +} diff --git a/mobile/packages/ui/lib/src/internal.dart b/mobile/packages/ui/lib/src/internal.dart new file mode 100644 index 0000000000..7f503927ff --- /dev/null +++ b/mobile/packages/ui/lib/src/internal.dart @@ -0,0 +1,6 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/src/translation.dart'; + +extension TranslationHelper on BuildContext { + ImmichTranslations get translations => ImmichTranslationProvider.of(this); +} diff --git a/mobile/packages/ui/lib/src/theme.dart b/mobile/packages/ui/lib/src/theme.dart new file mode 100644 index 0000000000..387723b8ce --- /dev/null +++ b/mobile/packages/ui/lib/src/theme.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/src/constants.dart'; + +class ImmichThemeProvider extends StatelessWidget { + final ColorScheme colorScheme; + final Widget child; + + const ImmichThemeProvider({super.key, required this.colorScheme, required this.child}); + + @override + Widget build(BuildContext context) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: colorScheme, + brightness: colorScheme.brightness, + inputDecorationTheme: InputDecorationTheme( + floatingLabelBehavior: FloatingLabelBehavior.always, + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: colorScheme.primary), + borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: colorScheme.primary), + borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)), + ), + errorBorder: OutlineInputBorder( + borderSide: BorderSide(color: colorScheme.error), + borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)), + ), + focusedErrorBorder: OutlineInputBorder( + borderSide: BorderSide(color: colorScheme.error), + borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)), + ), + labelStyle: TextStyle(color: colorScheme.primary, fontWeight: FontWeight.w600), + hintStyle: const TextStyle(fontSize: ImmichTextSize.body), + errorStyle: TextStyle(color: colorScheme.error, fontWeight: FontWeight.w600), + ), + ), + child: child, + ); + } +} diff --git a/mobile/packages/ui/lib/src/translation.dart b/mobile/packages/ui/lib/src/translation.dart new file mode 100644 index 0000000000..cd51f74422 --- /dev/null +++ b/mobile/packages/ui/lib/src/translation.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +class ImmichTranslations { + late String submit; + late String password; + + ImmichTranslations({String? submit, String? password}) { + this.submit = submit ?? 'Submit'; + this.password = password ?? 'Password'; + } +} + +class ImmichTranslationProvider extends InheritedWidget { + final ImmichTranslations? translations; + + const ImmichTranslationProvider({ + super.key, + this.translations, + required super.child, + }); + + static ImmichTranslations of(BuildContext context) { + final provider = context.dependOnInheritedWidgetOfExactType(); + return provider?.translations ?? ImmichTranslations(); + } + + @override + bool updateShouldNotify(covariant ImmichTranslationProvider oldWidget) { + return oldWidget.translations != translations; + } +} diff --git a/mobile/test/domain/repositories/sync_stream_repository_test.dart b/mobile/test/domain/repositories/sync_stream_repository_test.dart new file mode 100644 index 0000000000..d39446ada3 --- /dev/null +++ b/mobile/test/domain/repositories/sync_stream_repository_test.dart @@ -0,0 +1,185 @@ +import 'package:drift/drift.dart' as drift; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; +import 'package:openapi/api.dart'; + +SyncUserV1 _createUser({String id = 'user-1'}) { + return SyncUserV1( + id: id, + name: 'Test User', + email: 'test@test.com', + deletedAt: null, + avatarColor: null, + hasProfileImage: false, + profileChangedAt: DateTime(2024, 1, 1), + ); +} + +SyncAssetV1 _createAsset({ + required String id, + required String checksum, + required String fileName, + String ownerId = 'user-1', + int? width, + int? height, +}) { + return SyncAssetV1( + id: id, + checksum: checksum, + originalFileName: fileName, + type: AssetTypeEnum.IMAGE, + ownerId: ownerId, + isFavorite: false, + fileCreatedAt: DateTime(2024, 1, 1), + fileModifiedAt: DateTime(2024, 1, 1), + localDateTime: DateTime(2024, 1, 1), + visibility: AssetVisibility.timeline, + width: width, + height: height, + deletedAt: null, + duration: null, + libraryId: null, + livePhotoVideoId: null, + stackId: null, + thumbhash: null, + ); +} + +SyncAssetExifV1 _createExif({ + required String assetId, + required int width, + required int height, + required String orientation, +}) { + return SyncAssetExifV1( + assetId: assetId, + exifImageWidth: width, + exifImageHeight: height, + orientation: orientation, + city: null, + country: null, + dateTimeOriginal: null, + description: null, + exposureTime: null, + fNumber: null, + fileSizeInByte: null, + focalLength: null, + fps: null, + iso: null, + latitude: null, + lensModel: null, + longitude: null, + make: null, + model: null, + modifyDate: null, + profileDescription: null, + projectionType: null, + rating: null, + state: null, + timeZone: null, + ); +} + +void main() { + late Drift db; + late SyncStreamRepository sut; + + setUp(() async { + db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); + sut = SyncStreamRepository(db); + }); + + tearDown(() async { + await db.close(); + }); + + group('SyncStreamRepository - Dimension swapping based on orientation', () { + test('swaps dimensions for asset with rotated orientation', () async { + final flippedOrientations = ['5', '6', '7', '8', '90', '-90']; + + for (final orientation in flippedOrientations) { + final assetId = 'asset-$orientation-degrees'; + + await sut.updateUsersV1([_createUser()]); + + final asset = _createAsset( + id: assetId, + checksum: 'checksum-$orientation', + fileName: 'rotated_$orientation.jpg', + ); + await sut.updateAssetsV1([asset]); + + final exif = _createExif( + assetId: assetId, + width: 1920, + height: 1080, + orientation: orientation, // EXIF orientation value for 90 degrees CW + ); + await sut.updateAssetsExifV1([exif]); + + final query = db.remoteAssetEntity.select()..where((tbl) => tbl.id.equals(assetId)); + final result = await query.getSingle(); + + expect(result.width, equals(1080)); + expect(result.height, equals(1920)); + } + }); + + test('does not swap dimensions for asset with normal orientation', () async { + final nonFlippedOrientations = ['1', '2', '3', '4']; + for (final orientation in nonFlippedOrientations) { + final assetId = 'asset-$orientation-degrees'; + + await sut.updateUsersV1([_createUser()]); + + final asset = _createAsset(id: assetId, checksum: 'checksum-$orientation', fileName: 'normal_$orientation.jpg'); + await sut.updateAssetsV1([asset]); + + final exif = _createExif( + assetId: assetId, + width: 1920, + height: 1080, + orientation: orientation, // EXIF orientation value for normal + ); + await sut.updateAssetsExifV1([exif]); + + final query = db.remoteAssetEntity.select()..where((tbl) => tbl.id.equals(assetId)); + final result = await query.getSingle(); + + expect(result.width, equals(1920)); + expect(result.height, equals(1080)); + } + }); + + test('does not update dimensions if asset already has width and height', () async { + const assetId = 'asset-with-dimensions'; + const existingWidth = 1920; + const existingHeight = 1080; + const exifWidth = 3840; + const exifHeight = 2160; + + await sut.updateUsersV1([_createUser()]); + + final asset = _createAsset( + id: assetId, + checksum: 'checksum-with-dims', + fileName: 'with_dimensions.jpg', + width: existingWidth, + height: existingHeight, + ); + await sut.updateAssetsV1([asset]); + + final exif = _createExif(assetId: assetId, width: exifWidth, height: exifHeight, orientation: '6'); + await sut.updateAssetsExifV1([exif]); + + // Verify the asset still has original dimensions (not updated from EXIF) + final query = db.remoteAssetEntity.select()..where((tbl) => tbl.id.equals(assetId)); + final result = await query.getSingle(); + + expect(result.width, equals(existingWidth), reason: 'Width should remain as originally set'); + expect(result.height, equals(existingHeight), reason: 'Height should remain as originally set'); + }); + }); +} diff --git a/mobile/test/domain/services/asset.service_test.dart b/mobile/test/domain/services/asset.service_test.dart index ca9defc332..04e49f89f9 100644 --- a/mobile/test/domain/services/asset.service_test.dart +++ b/mobile/test/domain/services/asset.service_test.dart @@ -166,8 +166,8 @@ void main() { expect(result, 1080 / 1920); }); - test('handles various flipped EXIF orientations correctly', () async { - final flippedOrientations = ['5', '6', '7', '8', '90', '-90']; + test('should not flip remote asset dimensions', () async { + final flippedOrientations = ['1', '2', '3', '4', '5', '6', '7', '8', '90', '-90']; for (final orientation in flippedOrientations) { final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-$orientation', width: 1920, height: 1080); @@ -178,23 +178,7 @@ void main() { final result = await sut.getAspectRatio(remoteAsset); - expect(result, 1080 / 1920, reason: 'Orientation $orientation should flip dimensions'); - } - }); - - test('handles various non-flipped EXIF orientations correctly', () async { - final nonFlippedOrientations = ['1', '2', '3', '4']; - - for (final orientation in nonFlippedOrientations) { - final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-$orientation', width: 1920, height: 1080); - - final exif = ExifInfo(orientation: orientation); - - when(() => mockRemoteAssetRepository.getExif('remote-$orientation')).thenAnswer((_) async => exif); - - final result = await sut.getAspectRatio(remoteAsset); - - expect(result, 1920 / 1080, reason: 'Orientation $orientation should NOT flip dimensions'); + expect(result, 1920 / 1080, reason: 'Should not flipped remote asset dimensions for orientation $orientation'); } }); }); diff --git a/mobile/test/fixtures/sync_stream.stub.dart b/mobile/test/fixtures/sync_stream.stub.dart index 523984f966..69f6c1753f 100644 --- a/mobile/test/fixtures/sync_stream.stub.dart +++ b/mobile/test/fixtures/sync_stream.stub.dart @@ -94,25 +94,11 @@ abstract final class SyncStreamStub { required String ack, DateTime? trashedAt, }) { - return _assetV1( - id: id, - checksum: checksum, - deletedAt: trashedAt ?? DateTime(2025, 1, 1), - ack: ack, - ); + return _assetV1(id: id, checksum: checksum, deletedAt: trashedAt ?? DateTime(2025, 1, 1), ack: ack); } - static SyncEvent assetModified({ - required String id, - required String checksum, - required String ack, - }) { - return _assetV1( - id: id, - checksum: checksum, - deletedAt: null, - ack: ack, - ); + static SyncEvent assetModified({required String id, required String checksum, required String ack}) { + return _assetV1(id: id, checksum: checksum, deletedAt: null, ack: ack); } static SyncEvent _assetV1({ @@ -140,6 +126,8 @@ abstract final class SyncStreamStub { thumbhash: null, type: AssetTypeEnum.IMAGE, visibility: AssetVisibility.timeline, + width: null, + height: null, ), ack: ack, ); diff --git a/mobile/test/modules/utils/openapi_patching_test.dart b/mobile/test/modules/utils/openapi_patching_test.dart index b956c4bfb9..a577b0544f 100644 --- a/mobile/test/modules/utils/openapi_patching_test.dart +++ b/mobile/test/modules/utils/openapi_patching_test.dart @@ -45,5 +45,17 @@ void main() { addDefault(value, keys, defaultValue); expect(value['alpha']['beta'], 'gamma'); }); + + test('addDefault with null', () { + dynamic value = jsonDecode(""" +{ + "download": { + "archiveSize": 4294967296, + "includeEmbeddedVideos": false + } +} +"""); + expect(value['download']['unknownKey'], isNull); + }); }); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 50b33febbf..09de12010d 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2840,6 +2840,16 @@ "required": true }, "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetMediaResponseDto" + } + } + }, + "description": "Asset is a duplicate" + }, "201": { "content": { "application/json": { @@ -2848,7 +2858,7 @@ } } }, - "description": "" + "description": "Asset uploaded successfully" } }, "security": [ @@ -3218,6 +3228,112 @@ "x-immich-state": "Stable" } }, + "/assets/metadata": { + "delete": { + "description": "Delete metadata key-value pairs for multiple assets.", + "operationId": "deleteBulkAssetMetadata", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetMetadataBulkDeleteDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Delete asset metadata", + "tags": [ + "Assets" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2.5.0", + "state": "Beta" + } + ], + "x-immich-permission": "asset.update", + "x-immich-state": "Beta" + }, + "put": { + "description": "Upsert metadata key-value pairs for multiple assets.", + "operationId": "updateBulkAssetMetadata", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetMetadataBulkUpsertDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetMetadataBulkResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Upsert asset metadata", + "tags": [ + "Assets" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2.5.0", + "state": "Beta" + } + ], + "x-immich-permission": "asset.update", + "x-immich-state": "Beta" + } + }, "/assets/random": { "get": { "deprecated": true, @@ -3499,6 +3615,173 @@ "x-immich-state": "Stable" } }, + "/assets/{id}/edits": { + "delete": { + "description": "Removes all edit actions (crop, rotate, mirror) associated with the specified asset.", + "operationId": "removeAssetEdits", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Remove edits from an existing asset", + "tags": [ + "Assets" + ], + "x-immich-history": [ + { + "version": "v2.5.0", + "state": "Added" + }, + { + "version": "v2.5.0", + "state": "Beta" + } + ], + "x-immich-permission": "asset.edit.delete", + "x-immich-state": "Beta" + }, + "get": { + "description": "Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.", + "operationId": "getAssetEdits", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetEditsDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve edits for an existing asset", + "tags": [ + "Assets" + ], + "x-immich-history": [ + { + "version": "v2.5.0", + "state": "Added" + }, + { + "version": "v2.5.0", + "state": "Beta" + } + ], + "x-immich-permission": "asset.edit.get", + "x-immich-state": "Beta" + }, + "put": { + "description": "Apply a series of edit actions (crop, rotate, mirror) to the specified asset.", + "operationId": "editAsset", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetEditActionListDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetEditsDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Apply edits to an existing asset", + "tags": [ + "Assets" + ], + "x-immich-history": [ + { + "version": "v2.5.0", + "state": "Added" + }, + { + "version": "v2.5.0", + "state": "Beta" + } + ], + "x-immich-permission": "asset.edit.create", + "x-immich-state": "Beta" + } + }, "/assets/{id}/metadata": { "get": { "description": "Retrieve all metadata key-value pairs associated with the specified asset.", @@ -3652,7 +3935,7 @@ "required": true, "in": "path", "schema": { - "$ref": "#/components/schemas/AssetMetadataKey" + "type": "string" } } ], @@ -3711,7 +3994,7 @@ "required": true, "in": "path", "schema": { - "$ref": "#/components/schemas/AssetMetadataKey" + "type": "string" } } ], @@ -3828,6 +4111,15 @@ "description": "Downloads the original file of the specified asset.", "operationId": "downloadAsset", "parameters": [ + { + "name": "edited", + "required": false, + "in": "query", + "schema": { + "default": false, + "type": "boolean" + } + }, { "name": "id", "required": true, @@ -3949,7 +4241,7 @@ } } }, - "description": "" + "description": "Asset replaced successfully" } }, "security": [ @@ -3988,6 +4280,15 @@ "description": "Retrieve the thumbnail image for the specified asset.", "operationId": "viewAsset", "parameters": [ + { + "name": "edited", + "required": false, + "in": "query", + "schema": { + "default": false, + "type": "boolean" + } + }, { "name": "id", "required": true, @@ -15486,6 +15787,128 @@ ], "type": "object" }, + "AssetEditAction": { + "enum": [ + "crop", + "rotate", + "mirror" + ], + "type": "string" + }, + "AssetEditActionCrop": { + "properties": { + "action": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetEditAction" + } + ] + }, + "parameters": { + "$ref": "#/components/schemas/CropParameters" + } + }, + "required": [ + "action", + "parameters" + ], + "type": "object" + }, + "AssetEditActionListDto": { + "properties": { + "edits": { + "description": "list of edits", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/AssetEditActionCrop" + }, + { + "$ref": "#/components/schemas/AssetEditActionRotate" + }, + { + "$ref": "#/components/schemas/AssetEditActionMirror" + } + ] + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "edits" + ], + "type": "object" + }, + "AssetEditActionMirror": { + "properties": { + "action": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetEditAction" + } + ] + }, + "parameters": { + "$ref": "#/components/schemas/MirrorParameters" + } + }, + "required": [ + "action", + "parameters" + ], + "type": "object" + }, + "AssetEditActionRotate": { + "properties": { + "action": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetEditAction" + } + ] + }, + "parameters": { + "$ref": "#/components/schemas/RotateParameters" + } + }, + "required": [ + "action", + "parameters" + ], + "type": "object" + }, + "AssetEditsDto": { + "properties": { + "assetId": { + "format": "uuid", + "type": "string" + }, + "edits": { + "description": "list of edits", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/AssetEditActionCrop" + }, + { + "$ref": "#/components/schemas/AssetEditActionRotate" + }, + { + "$ref": "#/components/schemas/AssetEditActionMirror" + } + ] + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "assetId", + "edits" + ], + "type": "object" + }, "AssetFaceCreateDto": { "properties": { "assetId": { @@ -15815,8 +16238,7 @@ "deviceAssetId", "deviceId", "fileCreatedAt", - "fileModifiedAt", - "metadata" + "fileModifiedAt" ], "type": "object" }, @@ -15891,20 +16313,98 @@ ], "type": "string" }, - "AssetMetadataKey": { - "enum": [ - "mobile-app" + "AssetMetadataBulkDeleteDto": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AssetMetadataBulkDeleteItemDto" + }, + "type": "array" + } + }, + "required": [ + "items" ], - "type": "string" + "type": "object" + }, + "AssetMetadataBulkDeleteItemDto": { + "properties": { + "assetId": { + "format": "uuid", + "type": "string" + }, + "key": { + "type": "string" + } + }, + "required": [ + "assetId", + "key" + ], + "type": "object" + }, + "AssetMetadataBulkResponseDto": { + "properties": { + "assetId": { + "type": "string" + }, + "key": { + "type": "string" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + }, + "value": { + "type": "object" + } + }, + "required": [ + "assetId", + "key", + "updatedAt", + "value" + ], + "type": "object" + }, + "AssetMetadataBulkUpsertDto": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AssetMetadataBulkUpsertItemDto" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "AssetMetadataBulkUpsertItemDto": { + "properties": { + "assetId": { + "format": "uuid", + "type": "string" + }, + "key": { + "type": "string" + }, + "value": { + "type": "object" + } + }, + "required": [ + "assetId", + "key", + "value" + ], + "type": "object" }, "AssetMetadataResponseDto": { "properties": { "key": { - "allOf": [ - { - "$ref": "#/components/schemas/AssetMetadataKey" - } - ] + "type": "string" }, "updatedAt": { "format": "date-time", @@ -15938,11 +16438,7 @@ "AssetMetadataUpsertItemDto": { "properties": { "key": { - "allOf": [ - { - "$ref": "#/components/schemas/AssetMetadataKey" - } - ] + "type": "string" }, "value": { "type": "object" @@ -16086,6 +16582,10 @@ "hasMetadata": { "type": "boolean" }, + "height": { + "nullable": true, + "type": "number" + }, "id": { "type": "string" }, @@ -16206,6 +16706,10 @@ "$ref": "#/components/schemas/AssetVisibility" } ] + }, + "width": { + "nullable": true, + "type": "number" } }, "required": [ @@ -16217,6 +16721,7 @@ "fileCreatedAt", "fileModifiedAt", "hasMetadata", + "height", "id", "isArchived", "isFavorite", @@ -16229,7 +16734,8 @@ "thumbhash", "type", "updatedAt", - "visibility" + "visibility", + "width" ], "type": "object" }, @@ -16593,6 +17099,37 @@ ], "type": "object" }, + "CropParameters": { + "properties": { + "height": { + "description": "Height of the crop", + "minimum": 1, + "type": "number" + }, + "width": { + "description": "Width of the crop", + "minimum": 1, + "type": "number" + }, + "x": { + "description": "Top-Left X coordinate of crop", + "minimum": 0, + "type": "number" + }, + "y": { + "description": "Top-Left Y coordinate of crop", + "minimum": 0, + "type": "number" + } + }, + "required": [ + "height", + "width", + "x", + "y" + ], + "type": "object" + }, "DatabaseBackupConfig": { "properties": { "cronExpression": { @@ -17029,6 +17566,7 @@ "AssetDetectFaces", "AssetDetectDuplicatesQueueAll", "AssetDetectDuplicates", + "AssetEditThumbnailGeneration", "AssetEncodeVideoQueueAll", "AssetEncodeVideo", "AssetEmptyTrash", @@ -17855,6 +18393,30 @@ }, "type": "object" }, + "MirrorAxis": { + "description": "Axis to mirror along", + "enum": [ + "horizontal", + "vertical" + ], + "type": "string" + }, + "MirrorParameters": { + "properties": { + "axis": { + "allOf": [ + { + "$ref": "#/components/schemas/MirrorAxis" + } + ], + "description": "Axis to mirror along" + } + }, + "required": [ + "axis" + ], + "type": "object" + }, "NotificationCreateDto": { "properties": { "data": { @@ -18335,6 +18897,10 @@ "asset.upload", "asset.replace", "asset.copy", + "asset.derive", + "asset.edit.get", + "asset.edit.create", + "asset.edit.delete", "album.create", "album.read", "album.update", @@ -19052,7 +19618,8 @@ "notifications", "backupDatabase", "ocr", - "workflow" + "workflow", + "editor" ], "type": "string" }, @@ -19159,6 +19726,9 @@ "duplicateDetection": { "$ref": "#/components/schemas/QueueResponseLegacyDto" }, + "editor": { + "$ref": "#/components/schemas/QueueResponseLegacyDto" + }, "faceDetection": { "$ref": "#/components/schemas/QueueResponseLegacyDto" }, @@ -19206,6 +19776,7 @@ "backgroundTask", "backupDatabase", "duplicateDetection", + "editor", "faceDetection", "facialRecognition", "library", @@ -19418,6 +19989,18 @@ ], "type": "object" }, + "RotateParameters": { + "properties": { + "angle": { + "description": "Rotation angle in degrees", + "type": "number" + } + }, + "required": [ + "angle" + ], + "type": "object" + }, "SearchAlbumResponseDto": { "properties": { "count": { @@ -21093,11 +21676,7 @@ "type": "string" }, "key": { - "allOf": [ - { - "$ref": "#/components/schemas/AssetMetadataKey" - } - ] + "type": "string" } }, "required": [ @@ -21112,11 +21691,7 @@ "type": "string" }, "key": { - "allOf": [ - { - "$ref": "#/components/schemas/AssetMetadataKey" - } - ] + "type": "string" }, "value": { "type": "object" @@ -21153,6 +21728,10 @@ "nullable": true, "type": "string" }, + "height": { + "nullable": true, + "type": "integer" + }, "id": { "type": "string" }, @@ -21199,6 +21778,10 @@ "$ref": "#/components/schemas/AssetVisibility" } ] + }, + "width": { + "nullable": true, + "type": "integer" } }, "required": [ @@ -21207,6 +21790,7 @@ "duration", "fileCreatedAt", "fileModifiedAt", + "height", "id", "isFavorite", "libraryId", @@ -21217,7 +21801,8 @@ "stackId", "thumbhash", "type", - "visibility" + "visibility", + "width" ], "type": "object" }, @@ -22070,6 +22655,9 @@ "backgroundTask": { "$ref": "#/components/schemas/JobSettingsDto" }, + "editor": { + "$ref": "#/components/schemas/JobSettingsDto" + }, "faceDetection": { "$ref": "#/components/schemas/JobSettingsDto" }, @@ -22109,6 +22697,7 @@ }, "required": [ "backgroundTask", + "editor", "faceDetection", "library", "metadataExtraction", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 9a1b6619a9..57fd237841 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -375,6 +375,7 @@ export type AssetResponseDto = { /** The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken. */ fileModifiedAt: string; hasMetadata: boolean; + height: number | null; id: string; isArchived: boolean; isFavorite: boolean; @@ -399,6 +400,7 @@ export type AssetResponseDto = { /** The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified. */ updatedAt: string; visibility: AssetVisibility; + width: number | null; }; export type ContributorCountResponseDto = { assetCount: number; @@ -497,7 +499,7 @@ export type AssetBulkDeleteDto = { ids: string[]; }; export type AssetMetadataUpsertItemDto = { - key: AssetMetadataKey; + key: string; value: object; }; export type AssetMediaCreateDto = { @@ -510,7 +512,7 @@ export type AssetMediaCreateDto = { filename?: string; isFavorite?: boolean; livePhotoVideoId?: string; - metadata: AssetMetadataUpsertItemDto[]; + metadata?: AssetMetadataUpsertItemDto[]; sidecarData?: Blob; visibility?: AssetVisibility; }; @@ -569,6 +571,27 @@ export type AssetJobsDto = { assetIds: string[]; name: AssetJobName; }; +export type AssetMetadataBulkDeleteItemDto = { + assetId: string; + key: string; +}; +export type AssetMetadataBulkDeleteDto = { + items: AssetMetadataBulkDeleteItemDto[]; +}; +export type AssetMetadataBulkUpsertItemDto = { + assetId: string; + key: string; + value: object; +}; +export type AssetMetadataBulkUpsertDto = { + items: AssetMetadataBulkUpsertItemDto[]; +}; +export type AssetMetadataBulkResponseDto = { + assetId: string; + key: string; + updatedAt: string; + value: object; +}; export type UpdateAssetDto = { dateTimeOriginal?: string; description?: string; @@ -579,8 +602,47 @@ export type UpdateAssetDto = { rating?: number; visibility?: AssetVisibility; }; +export type CropParameters = { + /** Height of the crop */ + height: number; + /** Width of the crop */ + width: number; + /** Top-Left X coordinate of crop */ + x: number; + /** Top-Left Y coordinate of crop */ + y: number; +}; +export type AssetEditActionCrop = { + action: AssetEditAction; + parameters: CropParameters; +}; +export type RotateParameters = { + /** Rotation angle in degrees */ + angle: number; +}; +export type AssetEditActionRotate = { + action: AssetEditAction; + parameters: RotateParameters; +}; +export type MirrorParameters = { + /** Axis to mirror along */ + axis: MirrorAxis; +}; +export type AssetEditActionMirror = { + action: AssetEditAction; + parameters: MirrorParameters; +}; +export type AssetEditsDto = { + assetId: string; + /** list of edits */ + edits: (AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror)[]; +}; +export type AssetEditActionListDto = { + /** list of edits */ + edits: (AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror)[]; +}; export type AssetMetadataResponseDto = { - key: AssetMetadataKey; + key: string; updatedAt: string; value: object; }; @@ -754,6 +816,7 @@ export type QueuesResponseLegacyDto = { backgroundTask: QueueResponseLegacyDto; backupDatabase: QueueResponseLegacyDto; duplicateDetection: QueueResponseLegacyDto; + editor: QueueResponseLegacyDto; faceDetection: QueueResponseLegacyDto; facialRecognition: QueueResponseLegacyDto; library: QueueResponseLegacyDto; @@ -1489,6 +1552,7 @@ export type JobSettingsDto = { }; export type SystemConfigJobDto = { backgroundTask: JobSettingsDto; + editor: JobSettingsDto; faceDetection: JobSettingsDto; library: JobSettingsDto; metadataExtraction: JobSettingsDto; @@ -2474,6 +2538,9 @@ export function uploadAsset({ key, slug, xImmichChecksum, assetMediaCreateDto }: assetMediaCreateDto: AssetMediaCreateDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetMediaResponseDto; + } | { status: 201; data: AssetMediaResponseDto; }>(`/assets${QS.query(QS.explode({ @@ -2567,6 +2634,33 @@ export function runAssetJobs({ assetJobsDto }: { body: assetJobsDto }))); } +/** + * Delete asset metadata + */ +export function deleteBulkAssetMetadata({ assetMetadataBulkDeleteDto }: { + assetMetadataBulkDeleteDto: AssetMetadataBulkDeleteDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/assets/metadata", oazapfts.json({ + ...opts, + method: "DELETE", + body: assetMetadataBulkDeleteDto + }))); +} +/** + * Upsert asset metadata + */ +export function updateBulkAssetMetadata({ assetMetadataBulkUpsertDto }: { + assetMetadataBulkUpsertDto: AssetMetadataBulkUpsertDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetMetadataBulkResponseDto[]; + }>("/assets/metadata", oazapfts.json({ + ...opts, + method: "PUT", + body: assetMetadataBulkUpsertDto + }))); +} /** * Get random assets */ @@ -2635,6 +2729,46 @@ export function updateAsset({ id, updateAssetDto }: { body: updateAssetDto }))); } +/** + * Remove edits from an existing asset + */ +export function removeAssetEdits({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/assets/${encodeURIComponent(id)}/edits`, { + ...opts, + method: "DELETE" + })); +} +/** + * Retrieve edits for an existing asset + */ +export function getAssetEdits({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetEditsDto; + }>(`/assets/${encodeURIComponent(id)}/edits`, { + ...opts + })); +} +/** + * Apply edits to an existing asset + */ +export function editAsset({ id, assetEditActionListDto }: { + id: string; + assetEditActionListDto: AssetEditActionListDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetEditsDto; + }>(`/assets/${encodeURIComponent(id)}/edits`, oazapfts.json({ + ...opts, + method: "PUT", + body: assetEditActionListDto + }))); +} /** * Get asset metadata */ @@ -2669,7 +2803,7 @@ export function updateAssetMetadata({ id, assetMetadataUpsertDto }: { */ export function deleteAssetMetadata({ id, key }: { id: string; - key: AssetMetadataKey; + key: string; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText(`/assets/${encodeURIComponent(id)}/metadata/${encodeURIComponent(key)}`, { ...opts, @@ -2681,7 +2815,7 @@ export function deleteAssetMetadata({ id, key }: { */ export function getAssetMetadataByKey({ id, key }: { id: string; - key: AssetMetadataKey; + key: string; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -2706,7 +2840,8 @@ export function getAssetOcr({ id }: { /** * Download original asset */ -export function downloadAsset({ id, key, slug }: { +export function downloadAsset({ edited, id, key, slug }: { + edited?: boolean; id: string; key?: string; slug?: string; @@ -2715,6 +2850,7 @@ export function downloadAsset({ id, key, slug }: { status: 200; data: Blob; }>(`/assets/${encodeURIComponent(id)}/original${QS.query(QS.explode({ + edited, key, slug }))}`, { @@ -2745,7 +2881,8 @@ export function replaceAsset({ id, key, slug, assetMediaReplaceDto }: { /** * View asset thumbnail */ -export function viewAsset({ id, key, size, slug }: { +export function viewAsset({ edited, id, key, size, slug }: { + edited?: boolean; id: string; key?: string; size?: AssetMediaSize; @@ -2755,6 +2892,7 @@ export function viewAsset({ id, key, size, slug }: { status: 200; data: Blob; }>(`/assets/${encodeURIComponent(id)}/thumbnail${QS.query(QS.explode({ + edited, key, size, slug @@ -5352,6 +5490,10 @@ export enum Permission { AssetUpload = "asset.upload", AssetReplace = "asset.replace", AssetCopy = "asset.copy", + AssetDerive = "asset.derive", + AssetEditGet = "asset.edit.get", + AssetEditCreate = "asset.edit.create", + AssetEditDelete = "asset.edit.delete", AlbumCreate = "album.create", AlbumRead = "album.read", AlbumUpdate = "album.update", @@ -5482,9 +5624,6 @@ export enum Permission { AdminSessionRead = "adminSession.read", AdminAuthUnlinkAll = "adminAuth.unlinkAll" } -export enum AssetMetadataKey { - MobileApp = "mobile-app" -} export enum AssetMediaStatus { Created = "created", Replaced = "replaced", @@ -5504,6 +5643,15 @@ export enum AssetJobName { RegenerateThumbnail = "regenerate-thumbnail", TranscodeVideo = "transcode-video" } +export enum AssetEditAction { + Crop = "crop", + Rotate = "rotate", + Mirror = "mirror" +} +export enum MirrorAxis { + Horizontal = "horizontal", + Vertical = "vertical" +} export enum AssetMediaSize { Fullsize = "fullsize", Preview = "preview", @@ -5534,7 +5682,8 @@ export enum QueueName { Notifications = "notifications", BackupDatabase = "backupDatabase", Ocr = "ocr", - Workflow = "workflow" + Workflow = "workflow", + Editor = "editor" } export enum QueueCommand { Start = "start", @@ -5579,6 +5728,7 @@ export enum JobName { AssetDetectFaces = "AssetDetectFaces", AssetDetectDuplicatesQueueAll = "AssetDetectDuplicatesQueueAll", AssetDetectDuplicates = "AssetDetectDuplicates", + AssetEditThumbnailGeneration = "AssetEditThumbnailGeneration", AssetEncodeVideoQueueAll = "AssetEncodeVideoQueueAll", AssetEncodeVideo = "AssetEncodeVideo", AssetEmptyTrash = "AssetEmptyTrash", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4ffc00639..287faee4f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,7 +67,7 @@ importers: version: 24.10.4 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) byte-size: specifier: ^9.0.0 version: 9.0.1 @@ -109,16 +109,16 @@ importers: version: 8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.0.0 - version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite-tsconfig-paths: specifier: ^6.0.0 - version: 6.0.3(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + version: 6.0.3(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest-fetch-mock: specifier: ^0.4.0 - version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) yaml: specifier: ^2.3.1 version: 2.8.2 @@ -169,7 +169,7 @@ importers: version: 18.3.1(react@18.3.1) tailwindcss: specifier: ^3.2.4 - version: 3.4.19(yaml@2.8.2) + version: 3.4.19(tsx@4.21.0)(yaml@2.8.2) url: specifier: ^0.11.0 version: 0.11.4 @@ -201,6 +201,9 @@ importers: '@immich/cli': specifier: file:../cli version: link:../cli + '@immich/e2e-auth-server': + specifier: file:../e2e-auth-server + version: link:../e2e-auth-server '@immich/sdk': specifier: file:../open-api/typescript-sdk version: link:../open-api/typescript-sdk @@ -216,9 +219,6 @@ importers: '@types/node': specifier: ^24.10.4 version: 24.10.4 - '@types/oidc-provider': - specifier: ^9.0.0 - version: 9.5.0 '@types/pg': specifier: ^8.15.1 version: 8.16.0 @@ -249,15 +249,9 @@ importers: globals: specifier: ^16.0.0 version: 16.5.0 - jose: - specifier: ^5.6.3 - version: 5.10.0 luxon: specifier: ^3.4.4 version: 3.7.2 - oidc-provider: - specifier: ^9.0.0 - version: 9.6.0 pg: specifier: ^8.11.3 version: 8.16.3 @@ -290,7 +284,22 @@ importers: version: 5.2.1(encoding@0.1.13) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + + e2e-auth-server: + devDependencies: + '@types/oidc-provider': + specifier: ^9.0.0 + version: 9.5.0 + jose: + specifier: ^5.6.3 + version: 5.10.0 + oidc-provider: + specifier: ^9.0.0 + version: 9.6.0 + tsx: + specifier: ^4.20.6 + version: 4.21.0 i18n: devDependencies: @@ -552,10 +561,13 @@ importers: version: 4.8.3 tailwindcss-preset-email: specifier: ^1.4.0 - version: 1.4.1(tailwindcss@3.4.19(yaml@2.8.2)) + version: 1.4.1(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)) thumbhash: specifier: ^0.1.1 version: 0.1.1 + transformation-matrix: + specifier: ^3.1.0 + version: 3.1.0 ua-parser-js: specifier: ^2.0.0 version: 2.0.7 @@ -655,7 +667,7 @@ importers: version: 13.15.10 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) eslint: specifier: ^9.14.0 version: 9.39.2(jiti@2.6.1) @@ -694,7 +706,7 @@ importers: version: 7.1.4 tailwindcss: specifier: ^3.4.0 - version: 3.4.19(yaml@2.8.2) + version: 3.4.19(tsx@4.21.0)(yaml@2.8.2) testcontainers: specifier: ^11.0.0 version: 11.11.0 @@ -709,10 +721,10 @@ importers: version: 1.5.9(@swc/core@1.15.8(@swc/helpers@0.5.17))(rollup@4.53.4) vite-tsconfig-paths: specifier: ^6.0.0 - version: 6.0.3(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + version: 6.0.3(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) web: dependencies: @@ -726,8 +738,8 @@ importers: specifier: file:../open-api/typescript-sdk version: link:../open-api/typescript-sdk '@immich/ui': - specifier: ^0.53.3 - version: 0.53.3(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1) + specifier: ^0.56.1 + version: 0.56.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1) '@mapbox/mapbox-gl-rtl-text': specifier: 0.2.3 version: 0.2.3(mapbox-gl@1.13.3) @@ -845,25 +857,25 @@ importers: version: 3.1.2 '@sveltejs/adapter-static': specifier: ^3.0.8 - version: 3.0.10(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2))) + version: 3.0.10(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) '@sveltejs/enhanced-img': specifier: ^0.9.0 - version: 0.9.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(rollup@4.53.4)(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + version: 0.9.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.53.4)(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/kit': specifier: ^2.27.1 - version: 2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + version: 2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/vite-plugin-svelte': specifier: 6.2.1 - version: 6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + version: 6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@tailwindcss/vite': specifier: ^4.1.7 - version: 4.1.18(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + version: 4.1.18(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@testing-library/jest-dom': specifier: ^6.4.2 version: 6.9.1 '@testing-library/svelte': specifier: ^5.2.8 - version: 5.3.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + version: 5.3.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@testing-library/user-event': specifier: ^14.5.2 version: 14.6.1(@testing-library/dom@10.4.1) @@ -887,7 +899,7 @@ importers: version: 1.5.6 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) dotenv: specifier: ^17.0.0 version: 17.2.3 @@ -947,10 +959,10 @@ importers: version: 8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.1.2 - version: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + version: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) packages: @@ -3075,8 +3087,8 @@ packages: peerDependencies: svelte: ^5.0.0 - '@immich/ui@0.53.3': - resolution: {integrity: sha512-Ax7ctU9KIZgET58+PoMQnf1XDOIH76Xa341TXDfLwF96F3fQZ/v4TA7Ycb6hmTwIYGU9arIgqGqQDbuuNxc2vA==} + '@immich/ui@0.56.1': + resolution: {integrity: sha512-W4uEQn9pxVKRvIV7sl9p6dU2r7xlVsMFxBeClxtXzSsiJEoE10uZwBIm0L9q17c4TQ/+lk9e/w1e4jNSvFqFwQ==} peerDependencies: svelte: ^5.0.0 @@ -7416,6 +7428,9 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + github-slugger@1.5.0: resolution: {integrity: sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==} @@ -10408,6 +10423,9 @@ packages: resolve-pathname@3.0.0: resolution: {integrity: sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve-protobuf-schema@2.1.0: resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} @@ -11317,6 +11335,9 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + transformation-matrix@3.1.0: + resolution: {integrity: sha512-oYubRWTi2tYFHAL2J8DLvPIqIYcYZ0fSOi2vmSy042Ho4jBW2ce6VP7QfD44t65WQz6bw5w1Pk22J7lcUpaTKA==} + tree-dump@1.1.0: resolution: {integrity: sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==} engines: {node: '>=10.0'} @@ -11369,6 +11390,11 @@ packages: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + tweetnacl@0.14.5: resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} @@ -15078,12 +15104,12 @@ snapshots: dependencies: svelte: 5.46.1 - '@immich/ui@0.53.3(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)': + '@immich/ui@0.56.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)': dependencies: '@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.46.1) '@internationalized/date': 3.10.0 '@mdi/js': 7.4.47 - bits-ui: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1) + bits-ui: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1) luxon: 3.7.2 simple-icons: 16.4.0 svelte: 5.46.1 @@ -16688,29 +16714,29 @@ snapshots: dependencies: acorn: 8.15.0 - '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))': + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - '@sveltejs/kit': 2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + '@sveltejs/kit': 2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - '@sveltejs/enhanced-img@0.9.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(rollup@4.53.4)(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2))': + '@sveltejs/enhanced-img@0.9.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.53.4)(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) magic-string: 0.30.21 sharp: 0.34.5 svelte: 5.46.1 svelte-parse-markup: 0.1.5(svelte@5.46.1) - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite-imagetools: 9.0.2(rollup@4.53.4) zimmerframe: 1.1.4 transitivePeerDependencies: - rollup - supports-color - '@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2))': + '@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@standard-schema/spec': 1.1.0 '@sveltejs/acorn-typescript': 1.0.8(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 @@ -16723,28 +16749,28 @@ snapshots: set-cookie-parser: 2.7.2 sirv: 3.0.2 svelte: 5.46.1 - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: '@opentelemetry/api': 1.9.0 - '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) debug: 4.4.3 svelte: 5.46.1 - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) debug: 4.4.3 deepmerge: 4.3.1 magic-string: 0.30.21 svelte: 5.46.1 - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) - vitefu: 1.1.1(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitefu: 1.1.1(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) transitivePeerDependencies: - supports-color @@ -16963,12 +16989,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - '@tailwindcss/vite@4.1.18(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2))': + '@tailwindcss/vite@4.1.18(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@tailwindcss/node': 4.1.18 '@tailwindcss/oxide': 4.1.18 tailwindcss: 4.1.18 - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@testing-library/dom@10.4.1': dependencies: @@ -16994,14 +17020,14 @@ snapshots: dependencies: svelte: 5.46.1 - '@testing-library/svelte@5.3.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2))': + '@testing-library/svelte@5.3.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/svelte-core': 1.0.0(svelte@5.46.1) svelte: 5.46.1 optionalDependencies: - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: @@ -17588,7 +17614,7 @@ snapshots: '@vercel/oidc@3.0.5': {} - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -17603,11 +17629,11 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -17622,7 +17648,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -17634,21 +17660,21 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2))': + '@vitest/mocker@3.2.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@3.2.4(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2))': + '@vitest/mocker@3.2.4(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@3.2.4': dependencies: @@ -18124,15 +18150,15 @@ snapshots: binary-extensions@2.3.0: {} - bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1): + bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1): dependencies: '@floating-ui/core': 1.7.3 '@floating-ui/dom': 1.7.4 '@internationalized/date': 3.10.0 esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1) + runed: 0.35.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1) svelte: 5.46.1 - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1) + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1) tabbable: 6.3.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -20066,6 +20092,10 @@ snapshots: get-stream@6.0.1: {} + get-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + github-slugger@1.5.0: {} gl-matrix@3.4.4: {} @@ -22817,12 +22847,13 @@ snapshots: optionalDependencies: postcss: 8.5.6 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(yaml@2.8.2): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.6 + tsx: 4.21.0 yaml: 2.8.2 postcss-loader@7.3.4(postcss@8.5.6)(typescript@5.9.3)(webpack@5.103.0): @@ -23661,6 +23692,8 @@ snapshots: resolve-pathname@3.0.0: {} + resolve-pkg-maps@1.0.0: {} + resolve-protobuf-schema@2.1.0: dependencies: protocol-buffers-schema: 3.6.0 @@ -23769,14 +23802,14 @@ snapshots: dependencies: queue-microtask: 1.2.3 - runed@0.35.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1): + runed@0.35.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 svelte: 5.46.1 optionalDependencies: - '@sveltejs/kit': 2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + '@sveltejs/kit': 2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) rw@1.3.3: {} @@ -24505,10 +24538,10 @@ snapshots: dependencies: svelte-floating-ui: 1.5.8 - svelte-toolbelt@0.10.6(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1): + svelte-toolbelt@0.10.6(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1) + runed: 0.35.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1) style-to-object: 1.0.14 svelte: 5.46.1 transitivePeerDependencies: @@ -24575,21 +24608,21 @@ snapshots: optionalDependencies: tailwind-merge: 3.4.0 - tailwindcss-email-variants@3.0.5(tailwindcss@3.4.19(yaml@2.8.2)): + tailwindcss-email-variants@3.0.5(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)): dependencies: - tailwindcss: 3.4.19(yaml@2.8.2) + tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.2) - tailwindcss-mso@2.0.3(tailwindcss@3.4.19(yaml@2.8.2)): + tailwindcss-mso@2.0.3(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)): dependencies: - tailwindcss: 3.4.19(yaml@2.8.2) + tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.2) - tailwindcss-preset-email@1.4.1(tailwindcss@3.4.19(yaml@2.8.2)): + tailwindcss-preset-email@1.4.1(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)): dependencies: - tailwindcss: 3.4.19(yaml@2.8.2) - tailwindcss-email-variants: 3.0.5(tailwindcss@3.4.19(yaml@2.8.2)) - tailwindcss-mso: 2.0.3(tailwindcss@3.4.19(yaml@2.8.2)) + tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.2) + tailwindcss-email-variants: 3.0.5(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)) + tailwindcss-mso: 2.0.3(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)) - tailwindcss@3.4.19(yaml@2.8.2): + tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -24608,7 +24641,7 @@ snapshots: postcss: 8.5.6 postcss-import: 15.1.0(postcss@8.5.6) postcss-js: 4.1.0(postcss@8.5.6) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(yaml@2.8.2) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) postcss-nested: 6.2.0(postcss@8.5.6) postcss-selector-parser: 6.1.2 resolve: 1.22.11 @@ -24849,6 +24882,8 @@ snapshots: punycode: 2.3.1 optional: true + transformation-matrix@3.1.0: {} + tree-dump@1.1.0(tslib@2.8.1): dependencies: tslib: 2.8.1 @@ -24890,6 +24925,13 @@ snapshots: tsscmp@1.0.6: {} + tsx@4.21.0: + dependencies: + esbuild: 0.27.2 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + tweetnacl@0.14.5: {} type-check@0.4.0: @@ -25204,13 +25246,13 @@ snapshots: - rollup - supports-color - vite-node@3.2.4(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2): + vite-node@3.2.4(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - jiti @@ -25225,13 +25267,13 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2): + vite-node@3.2.4(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - jiti @@ -25246,18 +25288,18 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@6.0.3(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)): + vite-tsconfig-paths@6.0.3(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - typescript - vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2): + vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -25272,9 +25314,10 @@ snapshots: lightningcss: 1.30.2 sass: 1.97.1 terser: 5.44.1 + tsx: 4.21.0 yaml: 2.8.2 - vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2): + vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -25289,21 +25332,22 @@ snapshots: lightningcss: 1.30.2 sass: 1.97.1 terser: 5.44.1 + tsx: 4.21.0 yaml: 2.8.2 - vitefu@1.1.1(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)): + vitefu@1.1.1(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): optionalDependencies: - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)): + vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -25321,8 +25365,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) - vite-node: 3.2.4(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 @@ -25343,11 +25387,11 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -25365,8 +25409,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) - vite-node: 3.2.4(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 @@ -25387,11 +25431,11 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -25409,8 +25453,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) - vite-node: 3.2.4(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c7ec4739ae..f7f22e6f44 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - cli - docs - e2e + - e2e-auth-server - i18n - open-api/typescript-sdk - server diff --git a/server/Dockerfile b/server/Dockerfile index 918658e19f..566eb4c913 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -52,7 +52,7 @@ FROM builder AS plugins ARG TARGETPLATFORM -COPY --from=ghcr.io/jdx/mise:2025.11.3@sha256:ac26f5978c0e2783f3e68e58ce75eddb83e41b89bf8747c503bac2aa9baf22c5 /usr/local/bin/mise /usr/local/bin/mise +COPY --from=ghcr.io/jdx/mise:2026.1.1@sha256:a55c391f7582f34c58bce1a85090cd526596402ba77fc32b06c49b8404ef9c14 /usr/local/bin/mise /usr/local/bin/mise WORKDIR /usr/src/app COPY ./plugins/mise.toml ./plugins/ diff --git a/server/package.json b/server/package.json index 81f1181e66..2e54b11de8 100644 --- a/server/package.json +++ b/server/package.json @@ -110,6 +110,7 @@ "socket.io": "^4.8.1", "tailwindcss-preset-email": "^1.4.0", "thumbhash": "^0.1.1", + "transformation-matrix": "^3.1.0", "ua-parser-js": "^2.0.0", "uuid": "^11.1.0", "validator": "^13.12.0" @@ -128,8 +129,8 @@ "@types/cookie-parser": "^1.4.8", "@types/express": "^5.0.0", "@types/fluent-ffmpeg": "^2.1.21", - "@types/jsonwebtoken": "^9.0.10", "@types/js-yaml": "^4.0.9", + "@types/jsonwebtoken": "^9.0.10", "@types/lodash": "^4.14.197", "@types/luxon": "^3.6.2", "@types/mock-fs": "^4.13.1", diff --git a/server/src/config.ts b/server/src/config.ts index c18acd79f8..9b5fafd605 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -236,6 +236,7 @@ export const defaults = Object.freeze({ [QueueName.Notification]: { concurrency: 5 }, [QueueName.Ocr]: { concurrency: 1 }, [QueueName.Workflow]: { concurrency: 5 }, + [QueueName.Editor]: { concurrency: 2 }, }, logging: { enabled: true, diff --git a/server/src/controllers/asset-media.controller.ts b/server/src/controllers/asset-media.controller.ts index 843c2a3f3d..788ee0c0ed 100644 --- a/server/src/controllers/asset-media.controller.ts +++ b/server/src/controllers/asset-media.controller.ts @@ -15,7 +15,7 @@ import { UploadedFiles, UseInterceptors, } from '@nestjs/common'; -import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger'; +import { ApiBody, ApiConsumes, ApiHeader, ApiResponse, ApiTags } from '@nestjs/swagger'; import { NextFunction, Request, Response } from 'express'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { @@ -33,6 +33,7 @@ import { CheckExistingAssetsDto, UploadFieldName, } from 'src/dtos/asset-media.dto'; +import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { ApiTag, ImmichHeader, Permission, RouteKey } from 'src/enum'; import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor'; @@ -62,6 +63,16 @@ export class AssetMediaController { required: false, }) @ApiBody({ description: 'Asset Upload Information', type: AssetMediaCreateDto }) + @ApiResponse({ + status: 200, + description: 'Asset is a duplicate', + type: AssetMediaResponseDto, + }) + @ApiResponse({ + status: 201, + description: 'Asset uploaded successfully', + type: AssetMediaResponseDto, + }) @Endpoint({ summary: 'Upload asset', description: 'Uploads a new asset to the server.', @@ -94,15 +105,21 @@ export class AssetMediaController { async downloadAsset( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, + @Query() dto: AssetDownloadOriginalDto, @Res() res: Response, @Next() next: NextFunction, ) { - await sendFile(res, next, () => this.service.downloadOriginal(auth, id), this.logger); + await sendFile(res, next, () => this.service.downloadOriginal(auth, id, dto), this.logger); } @Put(':id/original') @UseInterceptors(FileUploadInterceptor) @ApiConsumes('multipart/form-data') + @ApiResponse({ + status: 200, + description: 'Asset replaced successfully', + type: AssetMediaResponseDto, + }) @Endpoint({ summary: 'Replace asset', description: 'Replace the asset with new file, without changing its id.', diff --git a/server/src/controllers/asset.controller.spec.ts b/server/src/controllers/asset.controller.spec.ts index 649c80e850..cf8b80be38 100644 --- a/server/src/controllers/asset.controller.spec.ts +++ b/server/src/controllers/asset.controller.spec.ts @@ -79,6 +79,74 @@ describe(AssetController.name, () => { }); }); + describe('PUT /assets/metadata', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).put(`/assets/metadata`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should require a valid assetId', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put('/assets/metadata') + .send({ items: [{ assetId: '123', key: 'test', value: {} }] }); + expect(status).toBe(400); + expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['items.0.assetId must be a UUID']))); + }); + + it('should require a key', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put('/assets/metadata') + .send({ items: [{ assetId: factory.uuid(), value: {} }] }); + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.badRequest( + expect.arrayContaining(['items.0.key must be a string', 'items.0.key should not be empty']), + ), + ); + }); + + it('should work', async () => { + const { status } = await request(ctx.getHttpServer()) + .put('/assets/metadata') + .send({ items: [{ assetId: factory.uuid(), key: AssetMetadataKey.MobileApp, value: { iCloudId: '123' } }] }); + expect(status).toBe(200); + }); + }); + + describe('DELETE /assets/metadata', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).delete(`/assets/metadata`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should require a valid assetId', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .delete('/assets/metadata') + .send({ items: [{ assetId: '123', key: 'test' }] }); + expect(status).toBe(400); + expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['items.0.assetId must be a UUID']))); + }); + + it('should require a key', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .delete('/assets/metadata') + .send({ items: [{ assetId: factory.uuid() }] }); + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.badRequest( + expect.arrayContaining(['items.0.key must be a string', 'items.0.key should not be empty']), + ), + ); + }); + + it('should work', async () => { + const { status } = await request(ctx.getHttpServer()) + .delete('/assets/metadata') + .send({ items: [{ assetId: factory.uuid(), key: AssetMetadataKey.MobileApp }] }); + expect(status).toBe(204); + }); + }); + describe('PUT /assets/:id', () => { it('should be an authenticated route', async () => { await request(ctx.getHttpServer()).get(`/assets/123`); @@ -169,12 +237,10 @@ describe(AssetController.name, () => { it('should require each item to have a valid key', async () => { const { status, body } = await request(ctx.getHttpServer()) .put(`/assets/${factory.uuid()}/metadata`) - .send({ items: [{ key: 'someKey' }] }); + .send({ items: [{ value: { some: 'value' } }] }); expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest( - expect.arrayContaining([expect.stringContaining('items.0.key must be one of the following values')]), - ), + factory.responses.badRequest(['items.0.key must be a string', 'items.0.key should not be empty']), ); }); @@ -224,15 +290,63 @@ describe(AssetController.name, () => { expect(status).toBe(400); expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID']))); }); + }); + + describe('PUT /assets/:id/edits', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/edits`).send({ edits: [] }); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should accept valid edits and pass to service correctly', async () => { + const edits = [ + { + action: 'crop', + parameters: { + x: 0, + y: 0, + width: 100, + height: 100, + }, + }, + ]; + + const assetId = factory.uuid(); + const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}/edits`).send({ + edits, + }); + + expect(service.editAsset).toHaveBeenCalledWith(undefined, assetId, { edits }); + expect(status).toBe(200); + }); + + it('should require a valid id', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/assets/123/edits`) + .send({ + edits: [ + { + action: 'crop', + parameters: { + x: 0, + y: 0, + width: 100, + height: 100, + }, + }, + ], + }); - it('should require a valid key', async () => { - const { status, body } = await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata/invalid`); expect(status).toBe(400); - expect(body).toEqual( - factory.responses.badRequest( - expect.arrayContaining([expect.stringContaining('key must be one of the following value')]), - ), - ); + expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID']))); + }); + + it('should require at least one edit', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/assets/${factory.uuid()}/edits`) + .send({ edits: [] }); + expect(status).toBe(400); + expect(body).toEqual(factory.responses.badRequest(['edits must contain at least 1 elements'])); }); }); @@ -247,13 +361,5 @@ describe(AssetController.name, () => { expect(status).toBe(400); expect(body).toEqual(factory.responses.badRequest(['id must be a UUID'])); }); - - it('should require a valid key', async () => { - const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/${factory.uuid()}/metadata/invalid`); - expect(status).toBe(400); - expect(body).toEqual( - factory.responses.badRequest([expect.stringContaining('key must be one of the following values')]), - ); - }); }); }); diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index bcc13fbc06..988623360b 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -7,6 +7,9 @@ import { AssetBulkUpdateDto, AssetCopyDto, AssetJobsDto, + AssetMetadataBulkDeleteDto, + AssetMetadataBulkResponseDto, + AssetMetadataBulkUpsertDto, AssetMetadataResponseDto, AssetMetadataRouteParams, AssetMetadataUpsertDto, @@ -17,6 +20,7 @@ import { UpdateAssetDto, } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto'; import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; import { ApiTag, Permission, RouteKey } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; @@ -120,6 +124,32 @@ export class AssetController { return this.service.copy(auth, dto); } + @Put('metadata') + @Authenticated({ permission: Permission.AssetUpdate }) + @Endpoint({ + summary: 'Upsert asset metadata', + description: 'Upsert metadata key-value pairs for multiple assets.', + history: new HistoryBuilder().added('v1').beta('v2.5.0'), + }) + updateBulkAssetMetadata( + @Auth() auth: AuthDto, + @Body() dto: AssetMetadataBulkUpsertDto, + ): Promise { + return this.service.upsertBulkMetadata(auth, dto); + } + + @Delete('metadata') + @Authenticated({ permission: Permission.AssetUpdate }) + @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete asset metadata', + description: 'Delete metadata key-value pairs for multiple assets.', + history: new HistoryBuilder().added('v1').beta('v2.5.0'), + }) + deleteBulkAssetMetadata(@Auth() auth: AuthDto, @Body() dto: AssetMetadataBulkDeleteDto): Promise { + return this.service.deleteBulkMetadata(auth, dto); + } + @Put(':id') @Authenticated({ permission: Permission.AssetUpdate }) @Endpoint({ @@ -197,4 +227,42 @@ export class AssetController { deleteAssetMetadata(@Auth() auth: AuthDto, @Param() { id, key }: AssetMetadataRouteParams): Promise { return this.service.deleteMetadataByKey(auth, id, key); } + + @Get(':id/edits') + @Authenticated({ permission: Permission.AssetEditGet }) + @Endpoint({ + summary: 'Retrieve edits for an existing asset', + description: 'Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.', + history: new HistoryBuilder().added('v2.5.0').beta('v2.5.0'), + }) + getAssetEdits(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.getAssetEdits(auth, id); + } + + @Put(':id/edits') + @Authenticated({ permission: Permission.AssetEditCreate }) + @Endpoint({ + summary: 'Apply edits to an existing asset', + description: 'Apply a series of edit actions (crop, rotate, mirror) to the specified asset.', + history: new HistoryBuilder().added('v2.5.0').beta('v2.5.0'), + }) + editAsset( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: AssetEditActionListDto, + ): Promise { + return this.service.editAsset(auth, id, dto); + } + + @Delete(':id/edits') + @Authenticated({ permission: Permission.AssetEditDelete }) + @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Remove edits from an existing asset', + description: 'Removes all edit actions (crop, rotate, mirror) associated with the specified asset.', + history: new HistoryBuilder().added('v2.5.0').beta('v2.5.0'), + }) + removeAssetEdits(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.removeAssetEdits(auth, id); + } } diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index 96623092f1..d688857de1 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -24,7 +24,13 @@ export interface MoveRequest { }; } -export type GeneratedImageType = AssetPathType.Preview | AssetPathType.Thumbnail | AssetPathType.FullSize; +export type GeneratedImageType = + | AssetPathType.Preview + | AssetPathType.Thumbnail + | AssetPathType.FullSize + | AssetPathType.EditedPreview + | AssetPathType.EditedThumbnail + | AssetPathType.EditedFullSize; export type GeneratedAssetType = GeneratedImageType | AssetPathType.EncodedVideo; export type ThumbnailPathEntity = { id: string; ownerId: string }; diff --git a/server/src/database.ts b/server/src/database.ts index 9f4494b720..95bc98bae4 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -272,6 +272,7 @@ export type AssetFace = { person?: Person | null; updatedAt: Date; updateId: string; + isVisible: boolean; }; export type Plugin = Selectable; @@ -340,6 +341,8 @@ export const columns = { 'asset.originalPath', 'asset.ownerId', 'asset.type', + 'asset.width', + 'asset.height', ], assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type'], authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'], @@ -390,6 +393,8 @@ export const columns = { 'asset.livePhotoVideoId', 'asset.stackId', 'asset.libraryId', + 'asset.width', + 'asset.height', ], syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'], syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'], diff --git a/server/src/dtos/asset-media.dto.ts b/server/src/dtos/asset-media.dto.ts index 755069d827..f5207d3048 100644 --- a/server/src/dtos/asset-media.dto.ts +++ b/server/src/dtos/asset-media.dto.ts @@ -19,6 +19,9 @@ export enum AssetMediaSize { export class AssetMediaOptionsDto { @ValidateEnum({ enum: AssetMediaSize, name: 'AssetMediaSize', optional: true }) size?: AssetMediaSize; + + @ValidateBoolean({ optional: true, default: false }) + edited?: boolean; } export enum UploadFieldName { @@ -78,7 +81,7 @@ export class AssetMediaCreateDto extends AssetMediaBase { @Optional() @ValidateNested({ each: true }) @IsArray() - metadata!: AssetMetadataUpsertItemDto[]; + metadata?: AssetMetadataUpsertItemDto[]; @ApiProperty({ type: 'string', format: 'binary', required: false }) [UploadFieldName.SIDECAR_DATA]?: any; diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index e228cd8f9f..1607c15085 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -3,6 +3,7 @@ import { Selectable } from 'kysely'; import { AssetFace, AssetFile, Exif, Stack, Tag, User } from 'src/database'; import { HistoryBuilder, Property } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; +import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto'; import { AssetFaceWithoutPersonResponseDto, @@ -13,6 +14,8 @@ import { import { TagResponseDto, mapTag } from 'src/dtos/tag.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { AssetStatus, AssetType, AssetVisibility } from 'src/enum'; +import { ImageDimensions } from 'src/types'; +import { getDimensions } from 'src/utils/asset.util'; import { hexOrBufferToBase64 } from 'src/utils/bytes'; import { mimeTypes } from 'src/utils/mime-types'; import { ValidateEnum } from 'src/validation'; @@ -34,6 +37,8 @@ export class SanitizedAssetResponseDto { duration!: string; livePhotoVideoId?: string | null; hasMetadata!: boolean; + width!: number | null; + height!: number | null; } export class AssetResponseDto extends SanitizedAssetResponseDto { @@ -107,6 +112,7 @@ export type MapAsset = { deviceId: string; duplicateId: string | null; duration: string | null; + edits?: AssetEditActionItem[]; encodedVideoPath: string | null; exifInfo?: Selectable | null; faces?: AssetFace[]; @@ -129,6 +135,8 @@ export type MapAsset = { tags?: Tag[]; thumbhash: Buffer | null; type: AssetType; + width: number | null; + height: number | null; }; export class AssetStackResponseDto { @@ -147,7 +155,11 @@ export type AssetMapOptions = { }; // TODO: this is inefficient -const peopleWithFaces = (faces?: AssetFace[]): PersonWithFacesResponseDto[] => { +const peopleWithFaces = ( + faces?: AssetFace[], + edits?: AssetEditActionItem[], + assetDimensions?: ImageDimensions, +): PersonWithFacesResponseDto[] => { const result: PersonWithFacesResponseDto[] = []; if (faces) { for (const face of faces) { @@ -156,7 +168,7 @@ const peopleWithFaces = (faces?: AssetFace[]): PersonWithFacesResponseDto[] => { if (existingPersonEntry) { existingPersonEntry.faces.push(face); } else { - result.push({ ...mapPerson(face.person!), faces: [mapFacesWithoutPerson(face)] }); + result.push({ ...mapPerson(face.person!), faces: [mapFacesWithoutPerson(face, edits, assetDimensions)] }); } } } @@ -190,10 +202,14 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset duration: entity.duration ?? '0:00:00.00000', livePhotoVideoId: entity.livePhotoVideoId, hasMetadata: false, + width: entity.width, + height: entity.height, }; return sanitizedAssetResponse as AssetResponseDto; } + const assetDimensions = entity.exifInfo ? getDimensions(entity.exifInfo) : undefined; + return { id: entity.id, createdAt: entity.createdAt, @@ -219,7 +235,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined, livePhotoVideoId: entity.livePhotoVideoId, tags: entity.tags?.map((tag) => mapTag(tag)), - people: peopleWithFaces(entity.faces), + people: peopleWithFaces(entity.faces, entity.edits, assetDimensions), unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)), checksum: hexOrBufferToBase64(entity.checksum)!, stack: withStack ? mapStack(entity) : undefined, @@ -227,5 +243,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset hasMetadata: true, duplicateId: entity.duplicateId, resized: true, + width: entity.width, + height: entity.height, }; } diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 03d1e31fb9..5ac79a9895 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -17,9 +17,9 @@ import { ValidateNested, } from 'class-validator'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; -import { AssetMetadataKey, AssetType, AssetVisibility } from 'src/enum'; +import { AssetType, AssetVisibility } from 'src/enum'; import { AssetStats } from 'src/repositories/asset.repository'; -import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation'; +import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation'; export class DeviceIdDto { @IsNotEmpty() @@ -142,8 +142,8 @@ export class AssetMetadataRouteParams { @ValidateUUID() id!: string; - @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) - key!: AssetMetadataKey; + @ValidateString() + key!: string; } export class AssetMetadataUpsertDto { @@ -154,26 +154,57 @@ export class AssetMetadataUpsertDto { } export class AssetMetadataUpsertItemDto { - @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) - key!: AssetMetadataKey; + @ValidateString() + key!: string; @IsObject() value!: object; } -export class AssetMetadataMobileAppDto { - @IsString() - @Optional() - iCloudId?: string; +export class AssetMetadataBulkUpsertDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AssetMetadataBulkUpsertItemDto) + items!: AssetMetadataBulkUpsertItemDto[]; +} + +export class AssetMetadataBulkUpsertItemDto { + @ValidateUUID() + assetId!: string; + + @ValidateString() + key!: string; + + @IsObject() + value!: object; +} + +export class AssetMetadataBulkDeleteDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AssetMetadataBulkDeleteItemDto) + items!: AssetMetadataBulkDeleteItemDto[]; +} + +export class AssetMetadataBulkDeleteItemDto { + @ValidateUUID() + assetId!: string; + + @ValidateString() + key!: string; } export class AssetMetadataResponseDto { - @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) - key!: AssetMetadataKey; + @ValidateString() + key!: string; value!: object; updatedAt!: Date; } +export class AssetMetadataBulkResponseDto extends AssetMetadataResponseDto { + assetId!: string; +} + export class AssetCopyDto { @ValidateUUID() sourceId!: string; @@ -197,6 +228,11 @@ export class AssetCopyDto { favorite?: boolean; } +export class AssetDownloadOriginalDto { + @ValidateBoolean({ optional: true, default: false }) + edited?: boolean; +} + export const mapStats = (stats: AssetStats): AssetStatsResponseDto => { return { images: stats[AssetType.Image], diff --git a/server/src/dtos/editing.dto.ts b/server/src/dtos/editing.dto.ts new file mode 100644 index 0000000000..56bd09f3ea --- /dev/null +++ b/server/src/dtos/editing.dto.ts @@ -0,0 +1,125 @@ +import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger'; +import { ClassConstructor, plainToInstance, Transform, Type } from 'class-transformer'; +import { ArrayMinSize, IsEnum, IsInt, Min, ValidateNested } from 'class-validator'; +import { IsAxisAlignedRotation, IsUniqueEditActions, ValidateUUID } from 'src/validation'; + +export enum AssetEditAction { + Crop = 'crop', + Rotate = 'rotate', + Mirror = 'mirror', +} + +export enum MirrorAxis { + Horizontal = 'horizontal', + Vertical = 'vertical', +} + +export class CropParameters { + @IsInt() + @Min(0) + @ApiProperty({ description: 'Top-Left X coordinate of crop' }) + x!: number; + + @IsInt() + @Min(0) + @ApiProperty({ description: 'Top-Left Y coordinate of crop' }) + y!: number; + + @IsInt() + @Min(1) + @ApiProperty({ description: 'Width of the crop' }) + width!: number; + + @IsInt() + @Min(1) + @ApiProperty({ description: 'Height of the crop' }) + height!: number; +} + +export class RotateParameters { + @IsAxisAlignedRotation() + @ApiProperty({ description: 'Rotation angle in degrees' }) + angle!: number; +} + +export class MirrorParameters { + @IsEnum(MirrorAxis) + @ApiProperty({ enum: MirrorAxis, enumName: 'MirrorAxis', description: 'Axis to mirror along' }) + axis!: MirrorAxis; +} + +class AssetEditActionBase { + @IsEnum(AssetEditAction) + @ApiProperty({ enum: AssetEditAction, enumName: 'AssetEditAction' }) + action!: AssetEditAction; +} + +export class AssetEditActionCrop extends AssetEditActionBase { + @ValidateNested() + @Type(() => CropParameters) + @ApiProperty({ type: CropParameters }) + parameters!: CropParameters; +} + +export class AssetEditActionRotate extends AssetEditActionBase { + @ValidateNested() + @Type(() => RotateParameters) + @ApiProperty({ type: RotateParameters }) + parameters!: RotateParameters; +} + +export class AssetEditActionMirror extends AssetEditActionBase { + @ValidateNested() + @Type(() => MirrorParameters) + @ApiProperty({ type: MirrorParameters }) + parameters!: MirrorParameters; +} + +export type AssetEditActionItem = + | { + action: AssetEditAction.Crop; + parameters: CropParameters; + } + | { + action: AssetEditAction.Rotate; + parameters: RotateParameters; + } + | { + action: AssetEditAction.Mirror; + parameters: MirrorParameters; + }; + +export type AssetEditActionParameter = { + [AssetEditAction.Crop]: CropParameters; + [AssetEditAction.Rotate]: RotateParameters; + [AssetEditAction.Mirror]: MirrorParameters; +}; + +type AssetEditActions = AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror; +const actionToClass: Record> = { + [AssetEditAction.Crop]: AssetEditActionCrop, + [AssetEditAction.Rotate]: AssetEditActionRotate, + [AssetEditAction.Mirror]: AssetEditActionMirror, +} as const; + +const getActionClass = (item: { action: AssetEditAction }): ClassConstructor => + actionToClass[item.action]; + +@ApiExtraModels(AssetEditActionRotate, AssetEditActionMirror, AssetEditActionCrop) +export class AssetEditActionListDto { + /** list of edits */ + @ArrayMinSize(1) + @IsUniqueEditActions() + @ValidateNested({ each: true }) + @Transform(({ value: edits }) => + Array.isArray(edits) ? edits.map((item) => plainToInstance(getActionClass(item), item)) : edits, + ) + @ApiProperty({ anyOf: Object.values(actionToClass).map((target) => ({ $ref: getSchemaPath(target) })) }) + edits!: AssetEditActionItem[]; +} + +export class AssetEditsDto extends AssetEditActionListDto { + @ValidateUUID() + @ApiProperty() + assetId!: string; +} diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index 3c90cfdc59..5bf6854d34 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -6,9 +6,12 @@ import { DateTime } from 'luxon'; import { AssetFace, Person } from 'src/database'; import { HistoryBuilder, Property } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; +import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { SourceType } from 'src/enum'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; +import { ImageDimensions } from 'src/types'; import { asDateString } from 'src/utils/date'; +import { transformFaceBoundingBox } from 'src/utils/transform'; import { IsDateStringFormat, MaxDateString, @@ -233,29 +236,37 @@ export function mapPerson(person: Person): PersonResponseDto { }; } -export function mapFacesWithoutPerson(face: Selectable): AssetFaceWithoutPersonResponseDto { +export function mapFacesWithoutPerson( + face: Selectable, + edits?: AssetEditActionItem[], + assetDimensions?: ImageDimensions, +): AssetFaceWithoutPersonResponseDto { return { id: face.id, - imageHeight: face.imageHeight, - imageWidth: face.imageWidth, - boundingBoxX1: face.boundingBoxX1, - boundingBoxX2: face.boundingBoxX2, - boundingBoxY1: face.boundingBoxY1, - boundingBoxY2: face.boundingBoxY2, + ...transformFaceBoundingBox( + { + boundingBoxX1: face.boundingBoxX1, + boundingBoxY1: face.boundingBoxY1, + boundingBoxX2: face.boundingBoxX2, + boundingBoxY2: face.boundingBoxY2, + imageWidth: face.imageWidth, + imageHeight: face.imageHeight, + }, + edits ?? [], + assetDimensions ?? { width: face.imageWidth, height: face.imageHeight }, + ), sourceType: face.sourceType, }; } -export function mapFaces(face: AssetFace, auth: AuthDto): AssetFaceResponseDto { +export function mapFaces( + face: AssetFace, + auth: AuthDto, + edits?: AssetEditActionItem[], + assetDimensions?: ImageDimensions, +): AssetFaceResponseDto { return { - id: face.id, - imageHeight: face.imageHeight, - imageWidth: face.imageWidth, - boundingBoxX1: face.boundingBoxX1, - boundingBoxX2: face.boundingBoxX2, - boundingBoxY1: face.boundingBoxY1, - boundingBoxY2: face.boundingBoxY2, - sourceType: face.sourceType, + ...mapFacesWithoutPerson(face, edits, assetDimensions), person: face.person?.ownerId === auth.user.id ? mapPerson(face.person) : null, }; } diff --git a/server/src/dtos/queue-legacy.dto.ts b/server/src/dtos/queue-legacy.dto.ts index 79155e3f74..e3b48fa869 100644 --- a/server/src/dtos/queue-legacy.dto.ts +++ b/server/src/dtos/queue-legacy.dto.ts @@ -66,6 +66,9 @@ export class QueuesResponseLegacyDto implements Record { diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index d6a557e2c5..6baf3c8ac7 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -4,7 +4,6 @@ import { ArrayMaxSize, IsInt, IsPositive, IsString } from 'class-validator'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AlbumUserRole, - AssetMetadataKey, AssetOrder, AssetType, AssetVisibility, @@ -118,6 +117,10 @@ export class SyncAssetV1 { livePhotoVideoId!: string | null; stackId!: string | null; libraryId!: string | null; + @ApiProperty({ type: 'integer' }) + width!: number | null; + @ApiProperty({ type: 'integer' }) + height!: number | null; } @ExtraModel() @@ -167,16 +170,14 @@ export class SyncAssetExifV1 { @ExtraModel() export class SyncAssetMetadataV1 { assetId!: string; - @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) - key!: AssetMetadataKey; + key!: string; value!: object; } @ExtraModel() export class SyncAssetMetadataDeleteV1 { assetId!: string; - @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) - key!: AssetMetadataKey; + key!: string; } @ExtraModel() diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index c835073c31..31b8145034 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -230,6 +230,12 @@ class SystemConfigJobDto implements Record @IsObject() @Type(() => JobSettingsDto) [QueueName.Workflow]!: JobSettingsDto; + + @ApiProperty({ type: JobSettingsDto }) + @ValidateNested() + @IsObject() + @Type(() => JobSettingsDto) + [QueueName.Editor]!: JobSettingsDto; } class SystemConfigLibraryScanDto { diff --git a/server/src/enum.ts b/server/src/enum.ts index 73ef4747bb..29718b0a8b 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -45,6 +45,9 @@ export enum AssetFileType { Preview = 'preview', Thumbnail = 'thumbnail', Sidecar = 'sidecar', + FullSizeEdited = 'fullsize_edited', + PreviewEdited = 'preview_edited', + ThumbnailEdited = 'thumbnail_edited', } export enum AlbumUserRole { @@ -106,6 +109,11 @@ export enum Permission { AssetUpload = 'asset.upload', AssetReplace = 'asset.replace', AssetCopy = 'asset.copy', + AssetDerive = 'asset.derive', + + AssetEditGet = 'asset.edit.get', + AssetEditCreate = 'asset.edit.create', + AssetEditDelete = 'asset.edit.delete', AlbumCreate = 'album.create', AlbumRead = 'album.read', @@ -363,6 +371,9 @@ export enum AssetPathType { Original = 'original', FullSize = 'fullsize', Preview = 'preview', + EditedFullSize = 'edited_fullsize', + EditedPreview = 'edited_preview', + EditedThumbnail = 'edited_thumbnail', Thumbnail = 'thumbnail', EncodedVideo = 'encoded_video', Sidecar = 'sidecar', @@ -560,6 +571,7 @@ export enum QueueName { BackupDatabase = 'backupDatabase', Ocr = 'ocr', Workflow = 'workflow', + Editor = 'editor', } export enum QueueJobStatus { @@ -578,6 +590,7 @@ export enum JobName { AssetDetectFaces = 'AssetDetectFaces', AssetDetectDuplicatesQueueAll = 'AssetDetectDuplicatesQueueAll', AssetDetectDuplicates = 'AssetDetectDuplicates', + AssetEditThumbnailGeneration = 'AssetEditThumbnailGeneration', AssetEncodeVideoQueueAll = 'AssetEncodeVideoQueueAll', AssetEncodeVideo = 'AssetEncodeVideo', AssetEmptyTrash = 'AssetEmptyTrash', diff --git a/server/src/queries/asset.edit.repository.sql b/server/src/queries/asset.edit.repository.sql new file mode 100644 index 0000000000..d11bc7fe70 --- /dev/null +++ b/server/src/queries/asset.edit.repository.sql @@ -0,0 +1,17 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- AssetEditRepository.replaceAll +begin +delete from "asset_edit" +where + "assetId" = $1 +rollback + +-- AssetEditRepository.getAll +select + "action", + "parameters" +from + "asset_edit" +where + "assetId" = $1 diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index ae2b5110c2..ccd90680bb 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -105,7 +105,21 @@ select where "asset_file"."assetId" = "asset"."id" ) as agg - ) as "files" + ) as "files", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_edit"."action", + "asset_edit"."parameters" + from + "asset_edit" + where + "asset_edit"."assetId" = "asset"."id" + ) as agg + ) as "edits" from "asset" inner join "asset_job_status" on "asset_job_status"."assetId" = "asset"."id" @@ -167,6 +181,20 @@ select "asset_file"."assetId" = "asset"."id" ) as agg ) as "files", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_edit"."action", + "asset_edit"."parameters" + from + "asset_edit" + where + "asset_edit"."assetId" = "asset"."id" + ) as agg + ) as "edits", to_json("asset_exif") as "exifInfo" from "asset" @@ -191,6 +219,8 @@ select "asset"."originalPath", "asset"."ownerId", "asset"."type", + "asset"."width", + "asset"."height", ( select coalesce(json_agg(agg), '[]') @@ -203,6 +233,7 @@ select where "asset_face"."assetId" = "asset"."id" and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" = $1 ) as agg ) as "faces", ( @@ -218,13 +249,13 @@ select "asset_file" where "asset_file"."assetId" = "asset"."id" - and "asset_file"."type" = $1 + and "asset_file"."type" = $2 ) as agg ) as "files" from "asset" where - "asset"."id" = $2 + "asset"."id" = $3 -- AssetJobRepository.getLockedPropertiesForMetadataExtraction select @@ -402,6 +433,7 @@ select where "asset_face"."assetId" = "asset"."id" and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" is true ) as agg ) as "faces", ( diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index f25a0798d2..aaa7dd46fb 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -76,6 +76,14 @@ where "assetId" = $1 and "key" = $2 +-- AssetRepository.deleteBulkMetadata +begin +delete from "asset_metadata" +where + "assetId" = $1 + and "key" = $2 +commit + -- AssetRepository.getByDayOfYear with "res" as ( @@ -174,6 +182,7 @@ select where "asset_face"."assetId" = "asset"."id" and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" is true ) as agg ) as "faces", ( @@ -375,14 +384,10 @@ with "asset_exif"."projectionType", coalesce( case - when asset_exif."exifImageHeight" = 0 - or asset_exif."exifImageWidth" = 0 then 1 - when "asset_exif"."orientation" in ('5', '6', '7', '8', '-90', '90') then round( - asset_exif."exifImageHeight"::numeric / asset_exif."exifImageWidth"::numeric, - 3 - ) + when asset."height" = 0 + or asset."width" = 0 then 1 else round( - asset_exif."exifImageWidth"::numeric / asset_exif."exifImageHeight"::numeric, + asset."width"::numeric / asset."height"::numeric, 3 ) end, diff --git a/server/src/queries/ocr.repository.sql b/server/src/queries/ocr.repository.sql index d9fe049031..fc8991dea0 100644 --- a/server/src/queries/ocr.repository.sql +++ b/server/src/queries/ocr.repository.sql @@ -15,6 +15,7 @@ from "asset_ocr" where "asset_ocr"."assetId" = $1 + and "asset_ocr"."isVisible" = $2 -- OcrRepository.upsert with @@ -66,3 +67,12 @@ with ) select 1 as "dummy" + +-- OcrRepository.updateOcrVisibilities +begin +update "ocr_search" +set + "text" = $1 +where + "assetId" = $2 +commit diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 8ad5b96bbc..356f5af8f6 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -35,6 +35,7 @@ from where "person"."ownerId" = $1 and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" is true and "person"."isHidden" = $2 group by "person"."id" @@ -63,6 +64,7 @@ from left join "asset_face" on "asset_face"."personId" = "person"."id" where "asset_face"."deletedAt" is null + and "asset_face"."isVisible" is true group by "person"."id" having @@ -89,6 +91,7 @@ from where "asset_face"."assetId" = $1 and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" = $2 order by "asset_face"."boundingBoxX1" asc @@ -229,6 +232,7 @@ from and "asset"."deletedAt" is null where "asset_face"."deletedAt" is null + and "asset_face"."isVisible" is true -- PersonRepository.getNumberOfPeople select @@ -250,6 +254,7 @@ where where "asset_face"."personId" = "person"."id" and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" = $2 and exists ( select from @@ -260,7 +265,7 @@ where and "asset"."deletedAt" is null ) ) - and "person"."ownerId" = $2 + and "person"."ownerId" = $3 -- PersonRepository.refreshFaces with @@ -321,6 +326,7 @@ from where "asset_face"."personId" = $1 and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" is true -- PersonRepository.getLatestFaceDate select diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index 7c1dc3b6b4..e7595b3d1e 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -69,6 +69,8 @@ select "asset"."livePhotoVideoId", "asset"."stackId", "asset"."libraryId", + "asset"."width", + "asset"."height", "album_asset"."updateId" from "album_asset" as "album_asset" @@ -99,6 +101,8 @@ select "asset"."livePhotoVideoId", "asset"."stackId", "asset"."libraryId", + "asset"."width", + "asset"."height", "asset"."updateId" from "asset" as "asset" @@ -134,7 +138,9 @@ select "asset"."duration", "asset"."livePhotoVideoId", "asset"."stackId", - "asset"."libraryId" + "asset"."libraryId", + "asset"."width", + "asset"."height" from "album_asset" as "album_asset" inner join "asset" on "asset"."id" = "album_asset"."assetId" @@ -448,6 +454,8 @@ select "asset"."livePhotoVideoId", "asset"."stackId", "asset"."libraryId", + "asset"."width", + "asset"."height", "asset"."updateId" from "asset" as "asset" @@ -536,6 +544,7 @@ where "asset_face"."updateId" < $1 and "asset_face"."updateId" > $2 and "asset"."ownerId" = $3 + and "asset_face"."isVisible" = $4 order by "asset_face"."updateId" asc @@ -740,6 +749,8 @@ select "asset"."livePhotoVideoId", "asset"."stackId", "asset"."libraryId", + "asset"."width", + "asset"."height", "asset"."updateId" from "asset" as "asset" @@ -789,6 +800,8 @@ select "asset"."livePhotoVideoId", "asset"."stackId", "asset"."libraryId", + "asset"."width", + "asset"."height", "asset"."updateId" from "asset" as "asset" diff --git a/server/src/repositories/asset-edit.repository.ts b/server/src/repositories/asset-edit.repository.ts new file mode 100644 index 0000000000..fdfbc4e1d8 --- /dev/null +++ b/server/src/repositories/asset-edit.repository.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import { Kysely } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { DummyValue, GenerateSql } from 'src/decorators'; +import { AssetEditActionItem } from 'src/dtos/editing.dto'; +import { DB } from 'src/schema'; + +@Injectable() +export class AssetEditRepository { + constructor(@InjectKysely() private db: Kysely) {} + + @GenerateSql({ + params: [DummyValue.UUID], + }) + async replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise { + return await this.db.transaction().execute(async (trx) => { + await trx.deleteFrom('asset_edit').where('assetId', '=', assetId).execute(); + + if (edits.length > 0) { + return trx + .insertInto('asset_edit') + .values(edits.map((edit) => ({ assetId, ...edit }))) + .returning(['action', 'parameters']) + .execute() as Promise; + } + + return []; + }); + } + + @GenerateSql({ + params: [DummyValue.UUID], + }) + async getAll(assetId: string): Promise { + return this.db + .selectFrom('asset_edit') + .select(['action', 'parameters']) + .where('assetId', '=', assetId) + .execute() as Promise; + } +} diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index 8beb053aac..39e658a5a8 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -11,6 +11,7 @@ import { asUuid, toJson, withDefaultVisibility, + withEdits, withExif, withExifInner, withFaces, @@ -72,6 +73,7 @@ export class AssetJobRepository { .selectFrom('asset') .select(['asset.id', 'asset.thumbhash']) .select(withFiles) + .select(withEdits) .where('asset.deletedAt', 'is', null) .where('asset.visibility', '!=', AssetVisibility.Hidden) .$if(!force, (qb) => @@ -113,6 +115,7 @@ export class AssetJobRepository { 'asset.type', ]) .select(withFiles) + .select(withEdits) .$call(withExifInner) .where('asset.id', '=', id) .executeTakeFirst(); @@ -200,7 +203,7 @@ export class AssetJobRepository { .selectFrom('asset') .select(['asset.id', 'asset.visibility']) .$call(withExifInner) - .select((eb) => withFaces(eb, true)) + .select((eb) => withFaces(eb, true, true)) .select((eb) => withFiles(eb, AssetFileType.Preview)) .where('asset.id', '=', id) .executeTakeFirst(); diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 7db3a76f12..7ae6a277b7 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -5,11 +5,12 @@ import { InjectKysely } from 'nestjs-kysely'; import { LockableProperty, Stack } from 'src/database'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetFileType, AssetMetadataKey, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; +import { AssetFileType, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; +import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table'; import { AssetTable } from 'src/schema/tables/asset.table'; import { anyUuid, @@ -19,6 +20,7 @@ import { truncatedDate, unnest, withDefaultVisibility, + withEdits, withExif, withFaces, withFacesAndPeople, @@ -111,6 +113,7 @@ interface GetByIdsRelations { smartSearch?: boolean; stack?: { assets?: boolean }; tags?: boolean; + edits?: boolean; } const distinctLocked = (eb: ExpressionBuilder, columns: T) => @@ -256,7 +259,11 @@ export class AssetRepository { .execute(); } - upsertMetadata(id: string, items: Array<{ key: AssetMetadataKey; value: object }>) { + upsertMetadata(id: string, items: Array<{ key: string; value: object }>) { + if (items.length === 0) { + return []; + } + return this.db .insertInto('asset_metadata') .values(items.map((item) => ({ assetId: id, ...item }))) @@ -269,8 +276,21 @@ export class AssetRepository { .execute(); } + upsertBulkMetadata(items: Insertable[]) { + return this.db + .insertInto('asset_metadata') + .values(items) + .onConflict((oc) => + oc + .columns(['assetId', 'key']) + .doUpdateSet((eb) => ({ key: eb.ref('excluded.key'), value: eb.ref('excluded.value') })), + ) + .returning(['assetId', 'key', 'value', 'updatedAt']) + .execute(); + } + @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - getMetadataByKey(assetId: string, key: AssetMetadataKey) { + getMetadataByKey(assetId: string, key: string) { return this.db .selectFrom('asset_metadata') .select(['key', 'value', 'updatedAt']) @@ -280,10 +300,23 @@ export class AssetRepository { } @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - async deleteMetadataByKey(id: string, key: AssetMetadataKey) { + async deleteMetadataByKey(id: string, key: string) { await this.db.deleteFrom('asset_metadata').where('assetId', '=', id).where('key', '=', key).execute(); } + @GenerateSql({ params: [[{ assetId: DummyValue.UUID, key: DummyValue.STRING }]] }) + async deleteBulkMetadata(items: Array<{ assetId: string; key: string }>) { + if (items.length === 0) { + return; + } + + await this.db.transaction().execute(async (tx) => { + for (const { assetId, key } of items) { + await tx.deleteFrom('asset_metadata').where('assetId', '=', assetId).where('key', '=', key).execute(); + } + }); + } + create(asset: Insertable) { return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow(); } @@ -441,7 +474,10 @@ export class AssetRepository { } @GenerateSql({ params: [DummyValue.UUID] }) - getById(id: string, { exifInfo, faces, files, library, owner, smartSearch, stack, tags }: GetByIdsRelations = {}) { + getById( + id: string, + { exifInfo, faces, files, library, owner, smartSearch, stack, tags, edits }: GetByIdsRelations = {}, + ) { return this.db .selectFrom('asset') .selectAll('asset') @@ -478,6 +514,7 @@ export class AssetRepository { ) .$if(!!files, (qb) => qb.select(withFiles)) .$if(!!tags, (qb) => qb.select(withTags)) + .$if(!!edits, (qb) => qb.select(withEdits)) .limit(1) .executeTakeFirst(); } @@ -505,10 +542,11 @@ export class AssetRepository { .selectAll('asset') .$call(withExif) .$call((qb) => qb.select(withFacesAndPeople)) + .$call((qb) => qb.select(withEdits)) .executeTakeFirst(); } - return this.getById(asset.id, { exifInfo: true, faces: { person: true } }); + return this.getById(asset.id, { exifInfo: true, faces: { person: true }, edits: true }); } async remove(asset: { id: string }): Promise { @@ -665,11 +703,9 @@ export class AssetRepository { .coalesce( eb .case() - .when(sql`asset_exif."exifImageHeight" = 0 or asset_exif."exifImageWidth" = 0`) + .when(sql`asset."height" = 0 or asset."width" = 0`) .then(eb.lit(1)) - .when('asset_exif.orientation', 'in', sql`('5', '6', '7', '8', '-90', '90')`) - .then(sql`round(asset_exif."exifImageHeight"::numeric / asset_exif."exifImageWidth"::numeric, 3)`) - .else(sql`round(asset_exif."exifImageWidth"::numeric / asset_exif."exifImageHeight"::numeric, 3)`) + .else(sql`round(asset."width"::numeric / asset."height"::numeric, 3)`) .end(), eb.lit(1), ) diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index c59110d674..361a2e7179 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -4,6 +4,7 @@ import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AppRepository } from 'src/repositories/app.repository'; +import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { AuditRepository } from 'src/repositories/audit.repository'; @@ -59,6 +60,7 @@ export const repositories = [ ApiKeyRepository, AppRepository, AssetRepository, + AssetEditRepository, AssetJobRepository, ConfigRepository, CronRepository, diff --git a/server/src/repositories/media.repository.spec.ts b/server/src/repositories/media.repository.spec.ts new file mode 100644 index 0000000000..a5380852ee --- /dev/null +++ b/server/src/repositories/media.repository.spec.ts @@ -0,0 +1,667 @@ +import sharp from 'sharp'; +import { AssetFace } from 'src/database'; +import { AssetEditAction, MirrorAxis } from 'src/dtos/editing.dto'; +import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; +import { SourceType } from 'src/enum'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { BoundingBox } from 'src/repositories/machine-learning.repository'; +import { MediaRepository } from 'src/repositories/media.repository'; +import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor'; +import { automock } from 'test/utils'; + +const getPixelColor = async (buffer: Buffer, x: number, y: number) => { + const metadata = await sharp(buffer).metadata(); + const width = metadata.width!; + const { data } = await sharp(buffer).raw().toBuffer({ resolveWithObject: true }); + const idx = (y * width + x) * 4; + return { + r: data[idx], + g: data[idx + 1], + b: data[idx + 2], + }; +}; + +const buildTestQuadImage = async () => { + // build a 4 quadrant image for testing mirroring + const base = sharp({ + create: { width: 1000, height: 1000, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }).png(); + + const tl = await sharp({ + create: { width: 500, height: 500, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .png() + .toBuffer(); + + const tr = await sharp({ + create: { width: 500, height: 500, channels: 3, background: { r: 0, g: 255, b: 0 } }, + }) + .png() + .toBuffer(); + + const bl = await sharp({ + create: { width: 500, height: 500, channels: 3, background: { r: 0, g: 0, b: 255 } }, + }) + .png() + .toBuffer(); + + const br = await sharp({ + create: { width: 500, height: 500, channels: 3, background: { r: 255, g: 255, b: 0 } }, + }) + .png() + .toBuffer(); + + const image = base.composite([ + { input: tl, left: 0, top: 0 }, // top-left + { input: tr, left: 500, top: 0 }, // top-right + { input: bl, left: 0, top: 500 }, // bottom-left + { input: br, left: 500, top: 500 }, // bottom-right + ]); + + return image.png().toBuffer(); +}; + +describe(MediaRepository.name, () => { + let sut: MediaRepository; + + beforeEach(() => { + // eslint-disable-next-line no-sparse-arrays + sut = new MediaRepository(automock(LoggingRepository, { args: [, { getEnv: () => ({}) }], strict: false })); + }); + + describe('applyEdits (single actions)', () => { + it('should apply crop edit correctly', async () => { + const result = await sut['applyEdits']( + sharp({ + create: { + width: 1000, + height: 1000, + channels: 4, + background: { r: 255, g: 0, b: 0, alpha: 0.5 }, + }, + }).png(), + [ + { + action: AssetEditAction.Crop, + parameters: { + x: 100, + y: 200, + width: 700, + height: 300, + }, + }, + ], + ); + + const metadata = await result.toBuffer().then((buf) => sharp(buf).metadata()); + expect(metadata.width).toBe(700); + expect(metadata.height).toBe(300); + }); + it('should apply rotate edit correctly', async () => { + const result = await sut['applyEdits']( + sharp({ + create: { + width: 500, + height: 1000, + channels: 4, + background: { r: 255, g: 0, b: 0, alpha: 0.5 }, + }, + }).png(), + [ + { + action: AssetEditAction.Rotate, + parameters: { + angle: 90, + }, + }, + ], + ); + + const metadata = await result.toBuffer().then((buf) => sharp(buf).metadata()); + expect(metadata.width).toBe(1000); + expect(metadata.height).toBe(500); + }); + + it('should apply mirror edit correctly', async () => { + const resultHorizontal = await sut['applyEdits'](sharp(await buildTestQuadImage()), [ + { + action: AssetEditAction.Mirror, + parameters: { + axis: MirrorAxis.Horizontal, + }, + }, + ]); + + const bufferHorizontal = await resultHorizontal.toBuffer(); + const metadataHorizontal = await resultHorizontal.metadata(); + expect(metadataHorizontal.width).toBe(1000); + expect(metadataHorizontal.height).toBe(1000); + + expect(await getPixelColor(bufferHorizontal, 10, 10)).toEqual({ r: 0, g: 255, b: 0 }); + expect(await getPixelColor(bufferHorizontal, 990, 10)).toEqual({ r: 255, g: 0, b: 0 }); + expect(await getPixelColor(bufferHorizontal, 10, 990)).toEqual({ r: 255, g: 255, b: 0 }); + expect(await getPixelColor(bufferHorizontal, 990, 990)).toEqual({ r: 0, g: 0, b: 255 }); + + const resultVertical = await sut['applyEdits'](sharp(await buildTestQuadImage()), [ + { + action: AssetEditAction.Mirror, + parameters: { + axis: MirrorAxis.Vertical, + }, + }, + ]); + + const bufferVertical = await resultVertical.toBuffer(); + const metadataVertical = await resultVertical.metadata(); + expect(metadataVertical.width).toBe(1000); + expect(metadataVertical.height).toBe(1000); + + // top-left should now be bottom-left (blue) + expect(await getPixelColor(bufferVertical, 10, 10)).toEqual({ r: 0, g: 0, b: 255 }); + // top-right should now be bottom-right (yellow) + expect(await getPixelColor(bufferVertical, 990, 10)).toEqual({ r: 255, g: 255, b: 0 }); + // bottom-left should now be top-left (red) + expect(await getPixelColor(bufferVertical, 10, 990)).toEqual({ r: 255, g: 0, b: 0 }); + // bottom-right should now be top-right (blue) + expect(await getPixelColor(bufferVertical, 990, 990)).toEqual({ r: 0, g: 255, b: 0 }); + }); + }); + + describe('applyEdits (multiple sequential edits)', () => { + it('should apply horizontal mirror then vertical mirror (equivalent to 180° rotation)', async () => { + const imageBuffer = await buildTestQuadImage(); + const result = await sut['applyEdits'](sharp(imageBuffer), [ + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }, + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } }, + ]); + + const buffer = await result.png().toBuffer(); + const metadata = await sharp(buffer).metadata(); + expect(metadata.width).toBe(1000); + expect(metadata.height).toBe(1000); + + expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 255, g: 255, b: 0 }); + expect(await getPixelColor(buffer, 990, 10)).toEqual({ r: 0, g: 0, b: 255 }); + expect(await getPixelColor(buffer, 10, 990)).toEqual({ r: 0, g: 255, b: 0 }); + expect(await getPixelColor(buffer, 990, 990)).toEqual({ r: 255, g: 0, b: 0 }); + }); + + it('should apply rotate 90° then horizontal mirror', async () => { + const imageBuffer = await buildTestQuadImage(); + const result = await sut['applyEdits'](sharp(imageBuffer), [ + { action: AssetEditAction.Rotate, parameters: { angle: 90 } }, + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }, + ]); + + const buffer = await result.png().toBuffer(); + const metadata = await sharp(buffer).metadata(); + expect(metadata.width).toBe(1000); + expect(metadata.height).toBe(1000); + + expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 255, g: 0, b: 0 }); + expect(await getPixelColor(buffer, 990, 10)).toEqual({ r: 0, g: 0, b: 255 }); + expect(await getPixelColor(buffer, 10, 990)).toEqual({ r: 0, g: 255, b: 0 }); + expect(await getPixelColor(buffer, 990, 990)).toEqual({ r: 255, g: 255, b: 0 }); + }); + + it('should apply 180° rotation', async () => { + const imageBuffer = await buildTestQuadImage(); + const result = await sut['applyEdits'](sharp(imageBuffer), [ + { action: AssetEditAction.Rotate, parameters: { angle: 180 } }, + ]); + + const buffer = await result.png().toBuffer(); + const metadata = await sharp(buffer).metadata(); + expect(metadata.width).toBe(1000); + expect(metadata.height).toBe(1000); + + expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 255, g: 255, b: 0 }); + expect(await getPixelColor(buffer, 990, 10)).toEqual({ r: 0, g: 0, b: 255 }); + expect(await getPixelColor(buffer, 10, 990)).toEqual({ r: 0, g: 255, b: 0 }); + expect(await getPixelColor(buffer, 990, 990)).toEqual({ r: 255, g: 0, b: 0 }); + }); + + it('should apply 270° rotations', async () => { + const imageBuffer = await buildTestQuadImage(); + const result = await sut['applyEdits'](sharp(imageBuffer), [ + { action: AssetEditAction.Rotate, parameters: { angle: 270 } }, + ]); + + const buffer = await result.png().toBuffer(); + const metadata = await sharp(buffer).metadata(); + expect(metadata.width).toBe(1000); + expect(metadata.height).toBe(1000); + + expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 0, g: 255, b: 0 }); + expect(await getPixelColor(buffer, 990, 10)).toEqual({ r: 255, g: 255, b: 0 }); + expect(await getPixelColor(buffer, 10, 990)).toEqual({ r: 255, g: 0, b: 0 }); + expect(await getPixelColor(buffer, 990, 990)).toEqual({ r: 0, g: 0, b: 255 }); + }); + + it('should apply crop then rotate 90°', async () => { + const imageBuffer = await buildTestQuadImage(); + const result = await sut['applyEdits'](sharp(imageBuffer), [ + { action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 1000, height: 500 } }, + { action: AssetEditAction.Rotate, parameters: { angle: 90 } }, + ]); + + const buffer = await result.png().toBuffer(); + const metadata = await sharp(buffer).metadata(); + expect(metadata.width).toBe(500); + expect(metadata.height).toBe(1000); + + expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 255, g: 0, b: 0 }); + expect(await getPixelColor(buffer, 10, 990)).toEqual({ r: 0, g: 255, b: 0 }); + }); + + it('should apply rotate 90° then crop', async () => { + const imageBuffer = await buildTestQuadImage(); + const result = await sut['applyEdits'](sharp(imageBuffer), [ + { action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 500, height: 1000 } }, + { action: AssetEditAction.Rotate, parameters: { angle: 90 } }, + ]); + + const buffer = await result.png().toBuffer(); + const metadata = await sharp(buffer).metadata(); + expect(metadata.width).toBe(1000); + expect(metadata.height).toBe(500); + + expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 0, g: 0, b: 255 }); + expect(await getPixelColor(buffer, 990, 10)).toEqual({ r: 255, g: 0, b: 0 }); + }); + + it('should apply vertical mirror then horizontal mirror then rotate 90°', async () => { + const imageBuffer = await buildTestQuadImage(); + const result = await sut['applyEdits'](sharp(imageBuffer), [ + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } }, + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }, + { action: AssetEditAction.Rotate, parameters: { angle: 90 } }, + ]); + + const buffer = await result.png().toBuffer(); + const metadata = await sharp(buffer).metadata(); + expect(metadata.width).toBe(1000); + expect(metadata.height).toBe(1000); + + expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 0, g: 255, b: 0 }); + expect(await getPixelColor(buffer, 990, 10)).toEqual({ r: 255, g: 255, b: 0 }); + expect(await getPixelColor(buffer, 10, 990)).toEqual({ r: 255, g: 0, b: 0 }); + expect(await getPixelColor(buffer, 990, 990)).toEqual({ r: 0, g: 0, b: 255 }); + }); + + it('should apply crop to single quadrant then mirror', async () => { + const imageBuffer = await buildTestQuadImage(); + const result = await sut['applyEdits'](sharp(imageBuffer), [ + { action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 500, height: 500 } }, + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }, + ]); + + const buffer = await result.png().toBuffer(); + const metadata = await sharp(buffer).metadata(); + expect(metadata.width).toBe(500); + expect(metadata.height).toBe(500); + + expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 255, g: 0, b: 0 }); + expect(await getPixelColor(buffer, 490, 10)).toEqual({ r: 255, g: 0, b: 0 }); + expect(await getPixelColor(buffer, 10, 490)).toEqual({ r: 255, g: 0, b: 0 }); + expect(await getPixelColor(buffer, 490, 490)).toEqual({ r: 255, g: 0, b: 0 }); + }); + + it('should apply all operations: crop, rotate, mirror', async () => { + const imageBuffer = await buildTestQuadImage(); + const result = await sut['applyEdits'](sharp(imageBuffer), [ + { action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 500, height: 1000 } }, + { action: AssetEditAction.Rotate, parameters: { angle: 90 } }, + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }, + ]); + + const buffer = await result.png().toBuffer(); + const metadata = await sharp(buffer).metadata(); + expect(metadata.width).toBe(1000); + expect(metadata.height).toBe(500); + + expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 255, g: 0, b: 0 }); + expect(await getPixelColor(buffer, 990, 10)).toEqual({ r: 0, g: 0, b: 255 }); + }); + }); + + describe('checkFaceVisibility', () => { + const baseFace: AssetFace = { + id: 'face-1', + assetId: 'asset-1', + personId: 'person-1', + boundingBoxX1: 100, + boundingBoxY1: 100, + boundingBoxX2: 200, + boundingBoxY2: 200, + imageWidth: 1000, + imageHeight: 800, + sourceType: SourceType.MachineLearning, + isVisible: true, + updatedAt: new Date(), + deletedAt: null, + updateId: '', + }; + + const assetDimensions = { width: 1000, height: 800 }; + + describe('with no crop edit', () => { + it('should return only currently invisible faces when no crop is provided', () => { + const visibleFace = { ...baseFace, id: 'face-visible', isVisible: true }; + const invisibleFace = { ...baseFace, id: 'face-invisible', isVisible: false }; + const faces = [visibleFace, invisibleFace]; + const result = checkFaceVisibility(faces, assetDimensions); + + expect(result.visible).toEqual([invisibleFace]); + expect(result.hidden).toEqual([]); + }); + + it('should return empty arrays when all faces are already visible and no crop is provided', () => { + const faces = [baseFace]; + const result = checkFaceVisibility(faces, assetDimensions); + + expect(result.visible).toEqual([]); + expect(result.hidden).toEqual([]); + }); + + it('should return all faces when all are invisible and no crop is provided', () => { + const face1 = { ...baseFace, id: 'face-1', isVisible: false }; + const face2 = { ...baseFace, id: 'face-2', isVisible: false }; + const faces = [face1, face2]; + const result = checkFaceVisibility(faces, assetDimensions); + + expect(result.visible).toEqual([face1, face2]); + expect(result.hidden).toEqual([]); + }); + }); + + describe('with crop edit', () => { + it('should mark face as visible when fully inside crop area', () => { + const crop: BoundingBox = { x1: 0, y1: 0, x2: 500, y2: 400 }; + const faces = [baseFace]; + const result = checkFaceVisibility(faces, assetDimensions, crop); + + expect(result.visible).toEqual(faces); + expect(result.hidden).toEqual([]); + }); + + it('should mark face as visible when more than 50% inside crop area', () => { + const crop: BoundingBox = { x1: 150, y1: 150, x2: 650, y2: 550 }; + // Face at (100,100)-(200,200), crop starts at (150,150) + // Overlap: (150,150)-(200,200) = 50x50 = 2500 + // Face area: 100x100 = 10000 + // Overlap percentage: 25% - should be hidden + const faces = [baseFace]; + const result = checkFaceVisibility(faces, assetDimensions, crop); + + expect(result.visible).toEqual([]); + expect(result.hidden).toEqual(faces); + }); + + it('should mark face as hidden when less than 50% inside crop area', () => { + const crop: BoundingBox = { x1: 250, y1: 250, x2: 750, y2: 650 }; + // Face completely outside crop area + const faces = [baseFace]; + const result = checkFaceVisibility(faces, assetDimensions, crop); + + expect(result.visible).toEqual([]); + expect(result.hidden).toEqual(faces); + }); + + it('should mark face as hidden when completely outside crop area', () => { + const crop: BoundingBox = { x1: 500, y1: 500, x2: 700, y2: 700 }; + const faces = [baseFace]; + const result = checkFaceVisibility(faces, assetDimensions, crop); + + expect(result.visible).toEqual([]); + expect(result.hidden).toEqual(faces); + }); + + it('should handle multiple faces with mixed visibility', () => { + const crop: BoundingBox = { x1: 0, y1: 0, x2: 300, y2: 300 }; + const faceInside: AssetFace = { + ...baseFace, + id: 'face-inside', + boundingBoxX1: 50, + boundingBoxY1: 50, + boundingBoxX2: 150, + boundingBoxY2: 150, + }; + const faceOutside: AssetFace = { + ...baseFace, + id: 'face-outside', + boundingBoxX1: 400, + boundingBoxY1: 400, + boundingBoxX2: 500, + boundingBoxY2: 500, + }; + const faces = [faceInside, faceOutside]; + const result = checkFaceVisibility(faces, assetDimensions, crop); + + expect(result.visible).toEqual([faceInside]); + expect(result.hidden).toEqual([faceOutside]); + }); + + it('should handle face at exactly 50% overlap threshold', () => { + // Face at (0,0)-(100,100), crop at (50,0)-(150,100) + // Overlap: (50,0)-(100,100) = 50x100 = 5000 + // Face area: 100x100 = 10000 + // Overlap percentage: 50% - exactly at threshold, should be visible + const faceAtEdge: AssetFace = { + ...baseFace, + id: 'face-edge', + boundingBoxX1: 0, + boundingBoxY1: 0, + boundingBoxX2: 100, + boundingBoxY2: 100, + }; + const crop: BoundingBox = { x1: 50, y1: 0, x2: 150, y2: 100 }; + const faces = [faceAtEdge]; + const result = checkFaceVisibility(faces, assetDimensions, crop); + + expect(result.visible).toEqual([faceAtEdge]); + expect(result.hidden).toEqual([]); + }); + }); + + describe('with scaled dimensions', () => { + it('should handle faces when asset dimensions differ from face image dimensions', () => { + // Face stored at 1000x800 resolution, but displaying at 500x400 + const scaledDimensions = { width: 500, height: 400 }; + const crop: BoundingBox = { x1: 0, y1: 0, x2: 250, y2: 200 }; + // Face at (100,100)-(200,200) on 1000x800 + // Scaled to 500x400: (50,50)-(100,100) + // Crop at (0,0)-(250,200) - face is fully inside + const faces = [baseFace]; + const result = checkFaceVisibility(faces, scaledDimensions, crop); + + expect(result.visible).toEqual(faces); + expect(result.hidden).toEqual([]); + }); + }); + }); + + describe('checkOcrVisibility', () => { + const baseOcr: AssetOcrResponseDto & { isVisible: boolean } = { + id: 'ocr-1', + assetId: 'asset-1', + x1: 0.1, + y1: 0.1, + x2: 0.2, + y2: 0.1, + x3: 0.2, + y3: 0.2, + x4: 0.1, + y4: 0.2, + boxScore: 0.9, + textScore: 0.85, + text: 'Test OCR', + isVisible: false, + }; + + const assetDimensions = { width: 1000, height: 800 }; + + describe('with no crop edit', () => { + it('should return only currently invisible OCR items when no crop is provided', () => { + const visibleOcr = { ...baseOcr, id: 'ocr-visible', isVisible: true }; + const invisibleOcr = { ...baseOcr, id: 'ocr-invisible', isVisible: false }; + const ocrs = [visibleOcr, invisibleOcr]; + const result = checkOcrVisibility(ocrs, assetDimensions); + + expect(result.visible).toEqual([invisibleOcr]); + expect(result.hidden).toEqual([]); + }); + + it('should return empty arrays when all OCR items are already visible and no crop is provided', () => { + const visibleOcr = { ...baseOcr, isVisible: true }; + const ocrs = [visibleOcr]; + const result = checkOcrVisibility(ocrs, assetDimensions); + + expect(result.visible).toEqual([]); + expect(result.hidden).toEqual([]); + }); + + it('should return all OCR items when all are invisible and no crop is provided', () => { + const ocr1 = { ...baseOcr, id: 'ocr-1', isVisible: false }; + const ocr2 = { ...baseOcr, id: 'ocr-2', isVisible: false }; + const ocrs = [ocr1, ocr2]; + const result = checkOcrVisibility(ocrs, assetDimensions); + + expect(result.visible).toEqual([ocr1, ocr2]); + expect(result.hidden).toEqual([]); + }); + }); + + describe('with crop edit', () => { + it('should mark OCR as visible when fully inside crop area', () => { + const crop: BoundingBox = { x1: 0, y1: 0, x2: 500, y2: 400 }; + // OCR box: (0.1,0.1)-(0.2,0.2) on 1000x800 = (100,80)-(200,160) + // Crop: (0,0)-(500,400) - OCR fully inside + const ocrs = [baseOcr]; + const result = checkOcrVisibility(ocrs, assetDimensions, crop); + + expect(result.visible).toEqual(ocrs); + expect(result.hidden).toEqual([]); + }); + + it('should mark OCR as hidden when completely outside crop area', () => { + const crop: BoundingBox = { x1: 500, y1: 500, x2: 700, y2: 700 }; + // OCR box: (100,80)-(200,160) - completely outside crop + const ocrs = [baseOcr]; + const result = checkOcrVisibility(ocrs, assetDimensions, crop); + + expect(result.visible).toEqual([]); + expect(result.hidden).toEqual(ocrs); + }); + + it('should mark OCR as hidden when less than 50% inside crop area', () => { + const crop: BoundingBox = { x1: 150, y1: 120, x2: 650, y2: 520 }; + // OCR box: (100,80)-(200,160) + // Crop: (150,120)-(650,520) + // Overlap: (150,120)-(200,160) = 50x40 = 2000 + // OCR area: 100x80 = 8000 + // Overlap percentage: 25% - should be hidden + const ocrs = [baseOcr]; + const result = checkOcrVisibility(ocrs, assetDimensions, crop); + + expect(result.visible).toEqual([]); + expect(result.hidden).toEqual(ocrs); + }); + + it('should handle multiple OCR items with mixed visibility', () => { + const crop: BoundingBox = { x1: 0, y1: 0, x2: 300, y2: 300 }; + const ocrInside = { + ...baseOcr, + id: 'ocr-inside', + }; + const ocrOutside = { + ...baseOcr, + id: 'ocr-outside', + x1: 0.5, + y1: 0.5, + x2: 0.6, + y2: 0.5, + x3: 0.6, + y3: 0.6, + x4: 0.5, + y4: 0.6, + }; + const ocrs = [ocrInside, ocrOutside]; + const result = checkOcrVisibility(ocrs, assetDimensions, crop); + + expect(result.visible).toEqual([ocrInside]); + expect(result.hidden).toEqual([ocrOutside]); + }); + + it('should handle OCR boxes with rotated/skewed polygons', () => { + // OCR with a rotated bounding box (not axis-aligned) + const rotatedOcr = { + ...baseOcr, + id: 'ocr-rotated', + x1: 0.15, + y1: 0.1, + x2: 0.25, + y2: 0.15, + x3: 0.2, + y3: 0.25, + x4: 0.1, + y4: 0.2, + }; + const crop: BoundingBox = { x1: 0, y1: 0, x2: 300, y2: 300 }; + const ocrs = [rotatedOcr]; + const result = checkOcrVisibility(ocrs, assetDimensions, crop); + + expect(result.visible).toEqual([rotatedOcr]); + expect(result.hidden).toEqual([]); + }); + }); + + describe('visibility is only affected by crop (not rotate or mirror)', () => { + it('should keep all OCR items visible when there is no crop regardless of other transforms', () => { + // Rotate and mirror edits don't affect visibility - only crop does + // The visibility functions only take an optional crop parameter + const ocrs = [baseOcr]; + + // Without any crop, all OCR items remain visible + const result = checkOcrVisibility(ocrs, assetDimensions); + + expect(result.visible).toEqual(ocrs); + expect(result.hidden).toEqual([]); + }); + + it('should only consider crop for visibility calculation', () => { + // Even if the image will be rotated/mirrored, visibility is determined + // solely by whether the OCR box overlaps with the crop area + const crop: BoundingBox = { x1: 0, y1: 0, x2: 300, y2: 300 }; + + const ocrInsideCrop = { + ...baseOcr, + id: 'ocr-inside', + // OCR at (0.1,0.1)-(0.2,0.2) = (100,80)-(200,160) on 1000x800, inside crop + }; + + const ocrOutsideCrop = { + ...baseOcr, + id: 'ocr-outside', + x1: 0.5, + y1: 0.5, + x2: 0.6, + y2: 0.5, + x3: 0.6, + y3: 0.6, + x4: 0.5, + y4: 0.6, + // OCR at (500,400)-(600,480) on 1000x800, outside crop + }; + + const ocrs = [ocrInsideCrop, ocrOutsideCrop]; + const result = checkOcrVisibility(ocrs, assetDimensions, crop); + + // OCR inside crop area is visible, OCR outside is hidden + // This is true regardless of any subsequent rotate/mirror operations + expect(result.visible).toEqual([ocrInsideCrop]); + expect(result.hidden).toEqual([ocrOutsideCrop]); + }); + }); + }); +}); diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index a8e96709ff..699c31ba5b 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -7,6 +7,7 @@ import { Writable } from 'node:stream'; import sharp from 'sharp'; import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants'; import { Exif } from 'src/database'; +import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { Colorspace, LogLevel, RawExtractedFormat } from 'src/enum'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { @@ -19,6 +20,7 @@ import { VideoInfo, } from 'src/types'; import { handlePromiseError } from 'src/utils/misc'; +import { createAffineMatrix } from 'src/utils/transform'; const probe = (input: string, options: string[]): Promise => new Promise((resolve, reject) => @@ -138,21 +140,48 @@ export class MediaRepository { } } - decodeImage(input: string | Buffer, options: DecodeToBufferOptions) { - return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true }); + async decodeImage(input: string | Buffer, options: DecodeToBufferOptions) { + const pipeline = await this.getImageDecodingPipeline(input, options); + return pipeline.raw().toBuffer({ resolveWithObject: true }); + } + + private async applyEdits(pipeline: sharp.Sharp, edits: AssetEditActionItem[]): Promise { + const affineEditOperations = edits.filter((edit) => edit.action !== 'crop'); + const matrix = createAffineMatrix(affineEditOperations); + + const crop = edits.find((edit) => edit.action === 'crop'); + const dimensions = await pipeline.metadata(); + + if (crop) { + pipeline = pipeline.extract({ + left: crop ? Math.round(crop.parameters.x) : 0, + top: crop ? Math.round(crop.parameters.y) : 0, + width: crop ? Math.round(crop.parameters.width) : dimensions.width || 0, + height: crop ? Math.round(crop.parameters.height) : dimensions.height || 0, + }); + } + + const { a, b, c, d } = matrix; + pipeline = pipeline.affine([ + [a, b], + [c, d], + ]); + + return pipeline; } async generateThumbnail(input: string | Buffer, options: GenerateThumbnailOptions, output: string): Promise { - await this.getImageDecodingPipeline(input, options) - .toFormat(options.format, { - quality: options.quality, - // this is default in libvips (except the threshold is 90), but we need to set it manually in sharp - chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0', - }) - .toFile(output); + const pipeline = await this.getImageDecodingPipeline(input, options); + const decoded = pipeline.toFormat(options.format, { + quality: options.quality, + // this is default in libvips (except the threshold is 90), but we need to set it manually in sharp + chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0', + }); + + await decoded.toFile(output); } - private getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) { + private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) { let pipeline = sharp(input, { // some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes failOn: options.processInvalidImages ? 'none' : 'error', @@ -175,8 +204,8 @@ export class MediaRepository { } } - if (options.crop) { - pipeline = pipeline.extract(options.crop); + if (options.edits && options.edits.length > 0) { + pipeline = await this.applyEdits(pipeline, options.edits); } if (options.size !== undefined) { @@ -186,14 +215,20 @@ export class MediaRepository { } async generateThumbhash(input: string | Buffer, options: GenerateThumbhashOptions): Promise { - const [{ rgbaToThumbHash }, { data, info }] = await Promise.all([ + const [{ rgbaToThumbHash }, decodingPipeline] = await Promise.all([ import('thumbhash'), - sharp(input, options) - .resize(100, 100, { fit: 'inside', withoutEnlargement: true }) - .raw() - .ensureAlpha() - .toBuffer({ resolveWithObject: true }), + this.getImageDecodingPipeline(input, { + colorspace: options.colorspace, + processInvalidImages: options.processInvalidImages, + raw: options.raw, + edits: options.edits, + }), ]); + + const pipeline = decodingPipeline.resize(100, 100, { fit: 'inside', withoutEnlargement: true }).raw().ensureAlpha(); + + const { data, info } = await pipeline.toBuffer({ resolveWithObject: true }); + return Buffer.from(rgbaToThumbHash(info.width, info.height, data)); } diff --git a/server/src/repositories/ocr.repository.ts b/server/src/repositories/ocr.repository.ts index a39f0d368c..63375cf57d 100644 --- a/server/src/repositories/ocr.repository.ts +++ b/server/src/repositories/ocr.repository.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { Insertable, Kysely, sql } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { DummyValue, GenerateSql } from 'src/decorators'; +import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; import { DB } from 'src/schema'; import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table'; @@ -15,8 +16,15 @@ export class OcrRepository { } @GenerateSql({ params: [DummyValue.UUID] }) - getByAssetId(id: string) { - return this.db.selectFrom('asset_ocr').selectAll('asset_ocr').where('asset_ocr.assetId', '=', id).execute(); + getByAssetId(id: string, options?: { isVisible?: boolean }) { + const isVisible = options === undefined ? true : options.isVisible; + + return this.db + .selectFrom('asset_ocr') + .selectAll('asset_ocr') + .where('asset_ocr.assetId', '=', id) + .$if(isVisible !== undefined, (qb) => qb.where('asset_ocr.isVisible', '=', isVisible!)) + .execute(); } deleteAll() { @@ -65,4 +73,40 @@ export class OcrRepository { return query.selectNoFrom(sql`1`.as('dummy')).execute(); } + + @GenerateSql({ params: [DummyValue.UUID, [], []] }) + async updateOcrVisibilities( + assetId: string, + visible: AssetOcrResponseDto[], + hidden: AssetOcrResponseDto[], + ): Promise { + await this.db.transaction().execute(async (trx) => { + if (visible.length > 0) { + await trx + .updateTable('asset_ocr') + .set({ isVisible: true }) + .where( + 'asset_ocr.id', + 'in', + visible.map((i) => i.id), + ) + .execute(); + } + + if (hidden.length > 0) { + await trx + .updateTable('asset_ocr') + .set({ isVisible: false }) + .where( + 'asset_ocr.id', + 'in', + hidden.map((i) => i.id), + ) + .execute(); + } + + const searchText = visible.map((item) => item.text.trim()).join(' '); + await trx.updateTable('ocr_search').set({ text: searchText }).where('assetId', '=', assetId).execute(); + }); + } } diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 725304938c..b03112821b 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable } from 'kysely'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; +import { AssetFace } from 'src/database'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AssetFileType, AssetVisibility, SourceType } from 'src/enum'; import { DB } from 'src/schema'; @@ -121,6 +122,7 @@ export class PersonRepository { .$if(!!options.sourceType, (qb) => qb.where('asset_face.sourceType', '=', options.sourceType!)) .$if(!!options.assetId, (qb) => qb.where('asset_face.assetId', '=', options.assetId!)) .where('asset_face.deletedAt', 'is', null) + .where('asset_face.isVisible', 'is', true) .stream(); } @@ -160,6 +162,7 @@ export class PersonRepository { ) .where('person.ownerId', '=', userId) .where('asset_face.deletedAt', 'is', null) + .where('asset_face.isVisible', 'is', true) .orderBy('person.isHidden', 'asc') .orderBy('person.isFavorite', 'desc') .having((eb) => @@ -208,19 +211,23 @@ export class PersonRepository { .selectAll('person') .leftJoin('asset_face', 'asset_face.personId', 'person.id') .where('asset_face.deletedAt', 'is', null) + .where('asset_face.isVisible', 'is', true) .having((eb) => eb.fn.count('asset_face.assetId'), '=', 0) .groupBy('person.id') .execute(); } @GenerateSql({ params: [DummyValue.UUID] }) - getFaces(assetId: string) { + getFaces(assetId: string, options?: { isVisible?: boolean }) { + const isVisible = options === undefined ? true : options.isVisible; + return this.db .selectFrom('asset_face') .selectAll('asset_face') .select(withPerson) .where('asset_face.assetId', '=', assetId) .where('asset_face.deletedAt', 'is', null) + .$if(isVisible !== undefined, (qb) => qb.where('asset_face.isVisible', '=', isVisible!)) .orderBy('asset_face.boundingBoxX1', 'asc') .execute(); } @@ -350,6 +357,7 @@ export class PersonRepository { ) .select((eb) => eb.fn.count(eb.fn('distinct', ['asset.id'])).as('count')) .where('asset_face.deletedAt', 'is', null) + .where('asset_face.isVisible', 'is', true) .executeTakeFirst(); return { @@ -368,6 +376,7 @@ export class PersonRepository { .selectFrom('asset_face') .whereRef('asset_face.personId', '=', 'person.id') .where('asset_face.deletedAt', 'is', null) + .where('asset_face.isVisible', '=', true) .where((eb) => eb.exists((eb) => eb @@ -495,6 +504,7 @@ export class PersonRepository { .selectAll('asset_face') .where('asset_face.personId', '=', personId) .where('asset_face.deletedAt', 'is', null) + .where('asset_face.isVisible', 'is', true) .executeTakeFirst(); } @@ -539,4 +549,37 @@ export class PersonRepository { } return this.db.selectFrom('person').select(['id', 'thumbnailPath']).where('id', 'in', ids).execute(); } + + @GenerateSql({ params: [[], []] }) + async updateVisibility(visible: AssetFace[], hidden: AssetFace[]): Promise { + if (visible.length === 0 && hidden.length === 0) { + return; + } + + await this.db.transaction().execute(async (trx) => { + if (visible.length > 0) { + await trx + .updateTable('asset_face') + .set({ isVisible: true }) + .where( + 'asset_face.id', + 'in', + visible.map(({ id }) => id), + ) + .execute(); + } + + if (hidden.length > 0) { + await trx + .updateTable('asset_face') + .set({ isVisible: false }) + .where( + 'asset_face.id', + 'in', + hidden.map(({ id }) => id), + ) + .execute(); + } + }); + } } diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index 437e32da16..511d7b589f 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -483,6 +483,7 @@ class AssetFaceSync extends BaseSync { ]) .leftJoin('asset', 'asset.id', 'asset_face.assetId') .where('asset.ownerId', '=', options.userId) + .where('asset_face.isVisible', '=', true) .stream(); } } diff --git a/server/src/repositories/websocket.repository.ts b/server/src/repositories/websocket.repository.ts index d87bf76351..c2da06786c 100644 --- a/server/src/repositories/websocket.repository.ts +++ b/server/src/repositories/websocket.repository.ts @@ -37,6 +37,7 @@ export interface ClientEventMap { AssetUploadReadyV1: [{ asset: SyncAssetV1; exif: SyncAssetExifV1 }]; AppRestartV1: [AppRestartEvent]; + AssetEditReadyV1: [{ assetId: string }]; } export type AuthFn = (client: Socket) => Promise; diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 9e206826e6..59c9f53d1a 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -28,6 +28,7 @@ import { AlbumUserTable } from 'src/schema/tables/album-user.table'; import { AlbumTable } from 'src/schema/tables/album.table'; import { ApiKeyTable } from 'src/schema/tables/api-key.table'; import { AssetAuditTable } from 'src/schema/tables/asset-audit.table'; +import { AssetEditTable } from 'src/schema/tables/asset-edit.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFaceAuditTable } from 'src/schema/tables/asset-face-audit.table'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; @@ -86,6 +87,7 @@ export class ImmichDatabase { AlbumTable, ApiKeyTable, AssetAuditTable, + AssetEditTable, AssetFaceTable, AssetFaceAuditTable, AssetMetadataTable, @@ -179,6 +181,7 @@ export interface DB { asset: AssetTable; asset_audit: AssetAuditTable; + asset_edit: AssetEditTable; asset_exif: AssetExifTable; asset_face: AssetFaceTable; asset_face_audit: AssetFaceAuditTable; diff --git a/server/src/schema/migrations/1763785815996-AddAssetWidthHeight.ts b/server/src/schema/migrations/1763785815996-AddAssetWidthHeight.ts new file mode 100644 index 0000000000..90ae32bebf --- /dev/null +++ b/server/src/schema/migrations/1763785815996-AddAssetWidthHeight.ts @@ -0,0 +1,28 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "asset" ADD COLUMN "width" integer;`.execute(db); + await sql`ALTER TABLE "asset" ADD COLUMN "height" integer;`.execute(db); + + // Populate width and height from exif data with orientation-aware swapping + await sql` + UPDATE "asset" + SET + "width" = CASE + WHEN "asset_exif"."orientation" IN ('5', '6', '7', '8', '-90', '90') THEN "asset_exif"."exifImageHeight" + ELSE "asset_exif"."exifImageWidth" + END, + "height" = CASE + WHEN "asset_exif"."orientation" IN ('5', '6', '7', '8', '-90', '90') THEN "asset_exif"."exifImageWidth" + ELSE "asset_exif"."exifImageHeight" + END + FROM "asset_exif" + WHERE "asset"."id" = "asset_exif"."assetId" + AND ("asset_exif"."exifImageWidth" IS NOT NULL OR "asset_exif"."exifImageHeight" IS NOT NULL) + `.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "asset" DROP COLUMN "width";`.execute(db); + await sql`ALTER TABLE "asset" DROP COLUMN "height";`.execute(db); +} diff --git a/server/src/schema/migrations/1764041175465-CreateAssetEditTable.ts b/server/src/schema/migrations/1764041175465-CreateAssetEditTable.ts new file mode 100644 index 0000000000..ef2ef74726 --- /dev/null +++ b/server/src/schema/migrations/1764041175465-CreateAssetEditTable.ts @@ -0,0 +1,22 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql` + CREATE TABLE "asset_edit" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "assetId" uuid NOT NULL, + "action" varchar NOT NULL, + "parameters" jsonb NOT NULL + ); + `.execute(db); + + await sql`ALTER TABLE "asset_edit" ADD CONSTRAINT "asset_edit_pkey" PRIMARY KEY ("id");`.execute(db); + await sql`ALTER TABLE "asset_edit" ADD CONSTRAINT "asset_edit_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "asset" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`CREATE INDEX "asset_edit_assetId_idx" ON "asset_edit" ("assetId")`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TABLE IF EXISTS "asset_edit";`.execute(db); +} diff --git a/server/src/schema/migrations/1764458955216-CreateIsVisibleColumns.ts b/server/src/schema/migrations/1764458955216-CreateIsVisibleColumns.ts new file mode 100644 index 0000000000..74e4d3bf17 --- /dev/null +++ b/server/src/schema/migrations/1764458955216-CreateIsVisibleColumns.ts @@ -0,0 +1,11 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "asset_ocr" ADD COLUMN "isVisible" boolean NOT NULL DEFAULT TRUE`.execute(db); + await sql`ALTER TABLE "asset_face" ADD COLUMN "isVisible" boolean NOT NULL DEFAULT TRUE`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "asset_ocr" DROP COLUMN "isVisible";`.execute(db); + await sql`ALTER TABLE "asset_face" DROP COLUMN "isVisible";`.execute(db); +} diff --git a/server/src/schema/tables/asset-edit.table.ts b/server/src/schema/tables/asset-edit.table.ts new file mode 100644 index 0000000000..84d95ca3c9 --- /dev/null +++ b/server/src/schema/tables/asset-edit.table.ts @@ -0,0 +1,17 @@ +import { AssetEditAction, AssetEditActionParameter } from 'src/dtos/editing.dto'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { Column, ForeignKeyColumn, Generated, PrimaryGeneratedColumn } from 'src/sql-tools'; + +export class AssetEditTable { + @PrimaryGeneratedColumn() + id!: Generated; + + @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) + assetId!: string; + + @Column() + action!: T; + + @Column({ type: 'jsonb' }) + parameters!: AssetEditActionParameter[T]; +} diff --git a/server/src/schema/tables/asset-face.table.ts b/server/src/schema/tables/asset-face.table.ts index 5041d945e2..8b156f2a17 100644 --- a/server/src/schema/tables/asset-face.table.ts +++ b/server/src/schema/tables/asset-face.table.ts @@ -78,4 +78,7 @@ export class AssetFaceTable { @UpdateIdColumn() updateId!: Generated; + + @Column({ type: 'boolean', default: true }) + isVisible!: Generated; } diff --git a/server/src/schema/tables/asset-metadata-audit.table.ts b/server/src/schema/tables/asset-metadata-audit.table.ts index 3b94ce6d1a..16272eacf7 100644 --- a/server/src/schema/tables/asset-metadata-audit.table.ts +++ b/server/src/schema/tables/asset-metadata-audit.table.ts @@ -1,5 +1,4 @@ import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; -import { AssetMetadataKey } from 'src/enum'; import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('asset_metadata_audit') @@ -11,7 +10,7 @@ export class AssetMetadataAuditTable { assetId!: string; @Column({ index: true }) - key!: AssetMetadataKey; + key!: string; @CreateDateColumn({ default: () => 'clock_timestamp()', index: true }) deletedAt!: Generated; diff --git a/server/src/schema/tables/asset-metadata.table.ts b/server/src/schema/tables/asset-metadata.table.ts index d529d6ad7b..8a7af1360f 100644 --- a/server/src/schema/tables/asset-metadata.table.ts +++ b/server/src/schema/tables/asset-metadata.table.ts @@ -32,7 +32,7 @@ export class AssetMetadataTable { assetId!: string; @PrimaryColumn({ type: 'character varying' }) - key!: AssetMetadataKey; + key!: AssetMetadataKey | string; @Column({ type: 'jsonb' }) value!: object; diff --git a/server/src/schema/tables/asset-ocr.table.ts b/server/src/schema/tables/asset-ocr.table.ts index 6ab159b531..b9b0838cbe 100644 --- a/server/src/schema/tables/asset-ocr.table.ts +++ b/server/src/schema/tables/asset-ocr.table.ts @@ -42,4 +42,7 @@ export class AssetOcrTable { @Column({ type: 'text' }) text!: string; + + @Column({ type: 'boolean', default: true }) + isVisible!: Generated; } diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts index b28fc99e4a..96ea0a98d8 100644 --- a/server/src/schema/tables/asset.table.ts +++ b/server/src/schema/tables/asset.table.ts @@ -137,4 +137,10 @@ export class AssetTable { @Column({ enum: asset_visibility_enum, default: AssetVisibility.Timeline }) visibility!: Generated; + + @Column({ type: 'integer', nullable: true }) + width!: number | null; + + @Column({ type: 'integer', nullable: true }) + height!: number | null; } diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 95eb8b3c97..c19a1ad92e 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -489,7 +489,7 @@ describe(AssetMediaService.name, () => { describe('downloadOriginal', () => { it('should require the asset.download permission', async () => { - await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.downloadOriginal(authStub.admin, 'asset-1', {})).rejects.toBeInstanceOf(BadRequestException); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, @@ -503,16 +503,16 @@ describe(AssetMediaService.name, () => { it('should throw an error if the asset is not found', async () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(NotFoundException); + await expect(sut.downloadOriginal(authStub.admin, 'asset-1', {})).rejects.toBeInstanceOf(NotFoundException); - expect(mocks.asset.getById).toHaveBeenCalledWith('asset-1', { files: true }); + expect(mocks.asset.getById).toHaveBeenCalledWith('asset-1', { files: true, edits: true }); }); it('should download a file', async () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); mocks.asset.getById.mockResolvedValue(assetStub.image); - await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).resolves.toEqual( + await expect(sut.downloadOriginal(authStub.admin, 'asset-1', {})).resolves.toEqual( new ImmichFileResponse({ path: '/original/path.jpg', fileName: 'asset-id.jpg', @@ -521,6 +521,104 @@ describe(AssetMediaService.name, () => { }), ); }); + + it('should download edited file by default when edits exist', async () => { + const editedAsset = { + ...assetStub.withCropEdit, + files: [ + ...assetStub.withCropEdit.files, + { + id: 'edited-file', + type: AssetFileType.FullSizeEdited, + path: '/uploads/user-id/fullsize/edited.jpg', + } as AssetFile, + ], + }; + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.asset.getById.mockResolvedValue(editedAsset); + + await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).resolves.toEqual( + new ImmichFileResponse({ + path: '/uploads/user-id/fullsize/edited.jpg', + fileName: 'asset-id.jpg', + contentType: 'image/jpeg', + cacheControl: CacheControl.PrivateWithCache, + }), + ); + }); + + it('should download edited file when edited=true', async () => { + const editedAsset = { + ...assetStub.withCropEdit, + files: [ + ...assetStub.withCropEdit.files, + { + id: 'edited-file', + type: AssetFileType.FullSizeEdited, + path: '/uploads/user-id/fullsize/edited.jpg', + } as AssetFile, + ], + }; + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.asset.getById.mockResolvedValue(editedAsset); + + await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).resolves.toEqual( + new ImmichFileResponse({ + path: '/uploads/user-id/fullsize/edited.jpg', + fileName: 'asset-id.jpg', + contentType: 'image/jpeg', + cacheControl: CacheControl.PrivateWithCache, + }), + ); + }); + + it('should download original file when edited=false', async () => { + const editedAsset = { + ...assetStub.withCropEdit, + files: [ + ...assetStub.withCropEdit.files, + { + id: 'edited-file', + type: AssetFileType.FullSizeEdited, + path: '/uploads/user-id/fullsize/edited.jpg', + } as AssetFile, + ], + }; + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.asset.getById.mockResolvedValue(editedAsset); + + await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: false })).resolves.toEqual( + new ImmichFileResponse({ + path: '/original/path.jpg', + fileName: 'asset-id.jpg', + contentType: 'image/jpeg', + cacheControl: CacheControl.PrivateWithCache, + }), + ); + }); + + it('should download original file when no edits exist', async () => { + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.asset.getById.mockResolvedValue(assetStub.image); + + await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).resolves.toEqual( + new ImmichFileResponse({ + path: '/original/path.jpg', + fileName: 'asset-id.jpg', + contentType: 'image/jpeg', + cacheControl: CacheControl.PrivateWithCache, + }), + ); + }); + + it('should throw a not found when edits exist but no edited file available', async () => { + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.asset.getById.mockResolvedValue(assetStub.withCropEdit); + + await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).rejects.toBeInstanceOf( + NotFoundException, + ); + }); }); describe('viewThumbnail', () => { @@ -620,6 +718,8 @@ describe(AssetMediaService.name, () => { }), ); }); + + // TODO: Edited asset tests }); describe('playbackVideo', () => { diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 2bb8530c1c..c2df6397b4 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -20,6 +20,7 @@ import { CheckExistingAssetsDto, UploadFieldName, } from 'src/dtos/asset-media.dto'; +import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetFileType, @@ -193,11 +194,26 @@ export class AssetMediaService extends BaseService { } } - async downloadOriginal(auth: AuthDto, id: string): Promise { + async downloadOriginal(auth: AuthDto, id: string, dto: AssetDownloadOriginalDto): Promise { await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: [id] }); const asset = await this.findOrFail(id); + if (asset.edits!.length > 0 && (dto.edited ?? false)) { + const { editedFullsizeFile } = getAssetFiles(asset.files ?? []); + + if (!editedFullsizeFile) { + throw new NotFoundException('Edited asset media not found'); + } + + return new ImmichFileResponse({ + path: editedFullsizeFile.path, + fileName: getFileNameWithoutExtension(asset.originalFileName) + getFilenameExtension(editedFullsizeFile.path), + contentType: mimeTypes.lookup(editedFullsizeFile.path), + cacheControl: CacheControl.PrivateWithCache, + }); + } + return new ImmichFileResponse({ path: asset.originalPath, fileName: asset.originalFileName, @@ -216,12 +232,20 @@ export class AssetMediaService extends BaseService { const asset = await this.findOrFail(id); const size = dto.size ?? AssetMediaSize.THUMBNAIL; - const { thumbnailFile, previewFile, fullsizeFile } = getAssetFiles(asset.files ?? []); + const files = getAssetFiles(asset.files ?? []); + + const requestingEdited = (dto.edited ?? false) && asset.edits!.length > 0; + const { fullsizeFile, previewFile, thumbnailFile } = { + fullsizeFile: requestingEdited ? files.editedFullsizeFile : files.fullsizeFile, + previewFile: requestingEdited ? files.editedPreviewFile : files.previewFile, + thumbnailFile: requestingEdited ? files.editedThumbnailFile : files.thumbnailFile, + }; + let filepath = previewFile?.path; if (size === AssetMediaSize.THUMBNAIL && thumbnailFile) { filepath = thumbnailFile.path; } else if (size === AssetMediaSize.FULLSIZE) { - if (mimeTypes.isWebSupportedImage(asset.originalPath)) { + if (mimeTypes.isWebSupportedImage(asset.originalPath) && !dto.edited) { // use original file for web supported images return { targetSize: 'original' }; } @@ -433,7 +457,7 @@ export class AssetMediaService extends BaseService { originalFileName: dto.filename || file.originalName, }); - if (dto.metadata) { + if (dto.metadata?.length) { await this.assetRepository.upsertMetadata(asset.id, dto.metadata); } @@ -465,7 +489,7 @@ export class AssetMediaService extends BaseService { } private async findOrFail(id: string) { - const asset = await this.assetRepository.getById(id, { files: true }); + const asset = await this.assetRepository.getById(id, { files: true, edits: true }); if (!asset) { throw new NotFoundException('Asset not found'); } diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 5e1cce2ccf..00708c9d1f 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -704,6 +704,7 @@ describe(AssetService.name, () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); mocks.ocr.getByAssetId.mockResolvedValue([ocr1, ocr2]); + mocks.asset.getById.mockResolvedValue(assetStub.image); await expect(sut.getOcr(authStub.admin, 'asset-1')).resolves.toEqual([ocr1, ocr2]); @@ -718,7 +719,7 @@ describe(AssetService.name, () => { it('should return empty array when no OCR data exists', async () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); mocks.ocr.getByAssetId.mockResolvedValue([]); - + mocks.asset.getById.mockResolvedValue(assetStub.image); await expect(sut.getOcr(authStub.admin, 'asset-1')).resolves.toEqual([]); expect(mocks.ocr.getByAssetId).toHaveBeenCalledWith('asset-1'); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index c584cf134f..26775a5ce4 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -11,6 +11,9 @@ import { AssetCopyDto, AssetJobName, AssetJobsDto, + AssetMetadataBulkDeleteDto, + AssetMetadataBulkResponseDto, + AssetMetadataBulkUpsertDto, AssetMetadataResponseDto, AssetMetadataUpsertDto, AssetStatsDto, @@ -18,11 +21,12 @@ import { mapStats, } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { AssetEditAction, AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto'; import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; import { AssetFileType, - AssetMetadataKey, AssetStatus, + AssetType, AssetVisibility, JobName, JobStatus, @@ -32,8 +36,17 @@ import { import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; import { requireElevatedPermission } from 'src/utils/access'; -import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util'; +import { + getAssetFiles, + getDimensions, + getMyPartnerIds, + isPanorama, + onAfterUnlink, + onBeforeLink, + onBeforeUnlink, +} from 'src/utils/asset.util'; import { updateLockedColumns } from 'src/utils/database'; +import { transformOcrBoundingBox } from 'src/utils/transform'; @Injectable() export class AssetService extends BaseService { @@ -68,6 +81,7 @@ export class AssetService extends BaseService { owner: true, faces: { person: true }, stack: { assets: true }, + edits: true, tags: true, }); @@ -345,11 +359,19 @@ export class AssetService extends BaseService { } } - const { fullsizeFile, previewFile, thumbnailFile, sidecarFile } = getAssetFiles(asset.files ?? []); - const files = [thumbnailFile?.path, previewFile?.path, fullsizeFile?.path, asset.encodedVideoPath]; + const assetFiles = getAssetFiles(asset.files ?? []); + const files = [ + assetFiles.thumbnailFile?.path, + assetFiles.previewFile?.path, + assetFiles.fullsizeFile?.path, + assetFiles.editedFullsizeFile?.path, + assetFiles.editedPreviewFile?.path, + assetFiles.editedThumbnailFile?.path, + asset.encodedVideoPath, + ]; if (deleteOnDisk && !asset.isOffline) { - files.push(sidecarFile?.path, asset.originalPath); + files.push(assetFiles.sidecarFile?.path, asset.originalPath); } await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: files.filter(Boolean) } }); @@ -378,7 +400,21 @@ export class AssetService extends BaseService { async getOcr(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] }); - return this.ocrRepository.getByAssetId(id); + const ocr = await this.ocrRepository.getByAssetId(id); + const asset = await this.assetRepository.getById(id, { exifInfo: true, edits: true }); + + if (!asset || !asset.exifInfo || !asset.edits) { + throw new BadRequestException('Asset not found'); + } + + const dimensions = getDimensions(asset.exifInfo); + + return ocr.map((item) => transformOcrBoundingBox(item, asset.edits!, dimensions)); + } + + async upsertBulkMetadata(auth: AuthDto, dto: AssetMetadataBulkUpsertDto): Promise { + await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.items.map((item) => item.assetId) }); + return this.assetRepository.upsertBulkMetadata(dto.items); } async upsertMetadata(auth: AuthDto, id: string, dto: AssetMetadataUpsertDto): Promise { @@ -386,7 +422,7 @@ export class AssetService extends BaseService { return this.assetRepository.upsertMetadata(id, dto.items); } - async getMetadataByKey(auth: AuthDto, id: string, key: AssetMetadataKey): Promise { + async getMetadataByKey(auth: AuthDto, id: string, key: string): Promise { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] }); const item = await this.assetRepository.getMetadataByKey(id, key); @@ -396,11 +432,16 @@ export class AssetService extends BaseService { return item; } - async deleteMetadataByKey(auth: AuthDto, id: string, key: AssetMetadataKey): Promise { + async deleteMetadataByKey(auth: AuthDto, id: string, key: string): Promise { await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] }); return this.assetRepository.deleteMetadataByKey(id, key); } + async deleteBulkMetadata(auth: AuthDto, dto: AssetMetadataBulkDeleteDto) { + await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.items.map((item) => item.assetId) }); + await this.assetRepository.deleteBulkMetadata(dto.items); + } + async run(auth: AuthDto, dto: AssetJobsDto) { await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.assetIds }); @@ -474,4 +515,78 @@ export class AssetService extends BaseService { await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id } }); } } + + async getAssetEdits(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] }); + const edits = await this.assetEditRepository.getAll(id); + return { + assetId: id, + edits, + }; + } + + async editAsset(auth: AuthDto, id: string, dto: AssetEditActionListDto): Promise { + await this.requireAccess({ auth, permission: Permission.AssetEditCreate, ids: [id] }); + + const asset = await this.assetRepository.getById(id, { exifInfo: true }); + if (!asset) { + throw new BadRequestException('Asset not found'); + } + + if (asset.type !== AssetType.Image) { + throw new BadRequestException('Only images can be edited'); + } + + if (asset.livePhotoVideoId) { + throw new BadRequestException('Editing live photos is not supported'); + } + + if (isPanorama(asset)) { + throw new BadRequestException('Editing panorama images is not supported'); + } + + if (asset.originalPath?.toLowerCase().endsWith('.gif')) { + throw new BadRequestException('Editing GIF images is not supported'); + } + + if (asset.originalPath?.toLowerCase().endsWith('.svg')) { + throw new BadRequestException('Editing SVG images is not supported'); + } + + // check that crop parameters will not go out of bounds + const { width: assetWidth, height: assetHeight } = getDimensions(asset.exifInfo!); + + if (!assetWidth || !assetHeight) { + throw new BadRequestException('Asset dimensions are not available for editing'); + } + + const crop = dto.edits.find((e) => e.action === AssetEditAction.Crop)?.parameters; + if (crop) { + const { x, y, width, height } = crop; + if (x + width > assetWidth || y + height > assetHeight) { + throw new BadRequestException('Crop parameters are out of bounds'); + } + } + + const newEdits = await this.assetEditRepository.replaceAll(id, dto.edits); + await this.jobRepository.queue({ name: JobName.AssetEditThumbnailGeneration, data: { id } }); + + // Return the asset and its applied edits + return { + assetId: id, + edits: newEdits, + }; + } + + async removeAssetEdits(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.AssetEditDelete, ids: [id] }); + + const asset = await this.assetRepository.getById(id); + if (!asset) { + throw new BadRequestException('Asset not found'); + } + + await this.assetEditRepository.replaceAll(id, []); + await this.jobRepository.queue({ name: JobName.AssetEditThumbnailGeneration, data: { id } }); + } } diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 9c422818b3..b3a50a07ae 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -11,6 +11,7 @@ import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AppRepository } from 'src/repositories/app.repository'; +import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { AuditRepository } from 'src/repositories/audit.repository'; @@ -69,6 +70,7 @@ export const BASE_SERVICE_DEPENDENCIES = [ ApiKeyRepository, AppRepository, AssetRepository, + AssetEditRepository, AssetJobRepository, AuditRepository, ConfigRepository, @@ -127,6 +129,7 @@ export class BaseService { protected apiKeyRepository: ApiKeyRepository, protected appRepository: AppRepository, protected assetRepository: AssetRepository, + protected assetEditRepository: AssetEditRepository, protected assetJobRepository: AssetJobRepository, protected auditRepository: AuditRepository, protected configRepository: ConfigRepository, diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index b57a203788..c47d75dc2a 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -96,6 +96,16 @@ export class JobService extends BaseService { break; } + case JobName.AssetEditThumbnailGeneration: { + const asset = await this.assetRepository.getById(item.data.id); + + if (asset) { + this.websocketRepository.clientSend('AssetEditReadyV1', asset.ownerId, { assetId: item.data.id }); + } + + break; + } + case JobName.AssetGenerateThumbnails: { if (!item.data.notify && item.data.source !== 'upload') { break; @@ -141,6 +151,8 @@ export class JobService extends BaseService { livePhotoVideoId: asset.livePhotoVideoId, stackId: asset.stackId, libraryId: asset.libraryId, + width: asset.width, + height: asset.height, }, exif: { assetId: exif.assetId, diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 8617930534..b94c5843ad 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -18,13 +18,17 @@ import { } from 'src/enum'; import { MediaService } from 'src/services/media.service'; import { JobCounts, RawImageInfo } from 'src/types'; -import { assetStub } from 'test/fixtures/asset.stub'; +import { assetStub, previewFile } from 'test/fixtures/asset.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { probeStub } from 'test/fixtures/media.stub'; import { personStub, personThumbnailStub } from 'test/fixtures/person.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; +const fullsizeBuffer = Buffer.from('embedded image data'); +const rawBuffer = Buffer.from('raw image data'); +const extractedBuffer = Buffer.from('embedded image file'); + describe(MediaService.name, () => { let sut: MediaService; let mocks: ServiceMocks; @@ -160,6 +164,42 @@ describe(MediaService.name, () => { expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); + + it('should queue assets with edits but missing edited thumbnails', async () => { + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit])); + mocks.person.getAll.mockReturnValue(makeStream()); + await sut.handleQueueGenerateThumbnails({ force: false }); + + expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(false); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { + name: JobName.AssetEditThumbnailGeneration, + data: { id: assetStub.withCropEdit.id }, + }, + ]); + + expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + }); + + it('should queue both regular and edited thumbnails for assets with edits when force is true', async () => { + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit])); + mocks.person.getAll.mockReturnValue(makeStream()); + await sut.handleQueueGenerateThumbnails({ force: true }); + + expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(true); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { + name: JobName.AssetGenerateThumbnails, + data: { id: assetStub.withCropEdit.id }, + }, + { + name: JobName.AssetEditThumbnailGeneration, + data: { id: assetStub.withCropEdit.id }, + }, + ]); + + expect(mocks.person.getAll).toHaveBeenCalledWith(undefined); + }); }); describe('handleQueueMigration', () => { @@ -222,16 +262,12 @@ describe(MediaService.name, () => { }); describe('handleGenerateThumbnails', () => { - let rawBuffer: Buffer; - let fullsizeBuffer: Buffer; - let extractedBuffer: Buffer; let rawInfo: RawImageInfo; beforeEach(() => { - fullsizeBuffer = Buffer.from('embedded image data'); - rawBuffer = Buffer.from('raw image data'); - extractedBuffer = Buffer.from('embedded image file'); rawInfo = { width: 100, height: 100, channels: 3 }; + mocks.person.getFaces.mockResolvedValue([]); + mocks.ocr.getByAssetId.mockResolvedValue([]); mocks.media.decodeImage.mockImplementation((input) => Promise.resolve( typeof input === 'string' @@ -281,7 +317,12 @@ describe(MediaService.name, () => { await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mocks.storage.unlink).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg'); + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.FileDelete, + data: { + files: expect.arrayContaining([previewFile.path]), + }, + }); }); it('should generate P3 thumbnails for a wide gamut image', async () => { @@ -313,6 +354,7 @@ describe(MediaService.name, () => { quality: 80, processInvalidImages: false, raw: rawInfo, + edits: [], }, expect.any(String), ); @@ -325,6 +367,7 @@ describe(MediaService.name, () => { quality: 80, processInvalidImages: false, raw: rawInfo, + edits: [], }, expect.any(String), ); @@ -334,6 +377,7 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, processInvalidImages: false, raw: rawInfo, + edits: [], }); expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ @@ -527,6 +571,7 @@ describe(MediaService.name, () => { quality: 80, processInvalidImages: false, raw: rawInfo, + edits: [], }, previewPath, ); @@ -539,6 +584,7 @@ describe(MediaService.name, () => { quality: 80, processInvalidImages: false, raw: rawInfo, + edits: [], }, thumbnailPath, ); @@ -572,6 +618,7 @@ describe(MediaService.name, () => { quality: 80, processInvalidImages: false, raw: rawInfo, + edits: [], }, previewPath, ); @@ -584,6 +631,7 @@ describe(MediaService.name, () => { quality: 80, processInvalidImages: false, raw: rawInfo, + edits: [], }, thumbnailPath, ); @@ -595,7 +643,12 @@ describe(MediaService.name, () => { await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mocks.storage.unlink).toHaveBeenCalledWith('/uploads/user-id/webp/path.ext'); + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.FileDelete, + data: { + files: expect.arrayContaining([previewFile.path]), + }, + }); }); it('should extract embedded image if enabled and available', async () => { @@ -641,7 +694,6 @@ describe(MediaService.name, () => { processInvalidImages: false, size: 1440, }); - expect(mocks.media.getImageDimensions).not.toHaveBeenCalled(); }); it('should resize original image if embedded image extraction is not enabled', async () => { @@ -657,7 +709,6 @@ describe(MediaService.name, () => { processInvalidImages: false, size: 1440, }); - expect(mocks.media.getImageDimensions).not.toHaveBeenCalled(); }); it('should process invalid images if enabled', async () => { @@ -691,7 +742,6 @@ describe(MediaService.name, () => { expect.objectContaining({ processInvalidImages: false }), ); - expect(mocks.media.getImageDimensions).not.toHaveBeenCalled(); vi.unstubAllEnvs(); }); @@ -722,6 +772,7 @@ describe(MediaService.name, () => { quality: 80, processInvalidImages: false, raw: rawInfo, + edits: [], }, expect.any(String), ); @@ -752,6 +803,7 @@ describe(MediaService.name, () => { quality: 80, processInvalidImages: false, raw: rawInfo, + edits: [], }, expect.any(String), ); @@ -764,6 +816,7 @@ describe(MediaService.name, () => { quality: 80, processInvalidImages: false, raw: rawInfo, + edits: [], }, expect.any(String), ); @@ -792,6 +845,7 @@ describe(MediaService.name, () => { quality: 80, processInvalidImages: false, raw: rawInfo, + edits: [], }, expect.any(String), ); @@ -804,6 +858,7 @@ describe(MediaService.name, () => { size: 1440, processInvalidImages: false, raw: rawInfo, + edits: [], }, expect.any(String), ); @@ -833,6 +888,7 @@ describe(MediaService.name, () => { quality: 80, processInvalidImages: false, raw: rawInfo, + edits: [], }, expect.any(String), ); @@ -888,6 +944,7 @@ describe(MediaService.name, () => { quality: 80, processInvalidImages: false, raw: rawInfo, + edits: [], }, expect.any(String), ); @@ -926,12 +983,166 @@ describe(MediaService.name, () => { quality: 90, processInvalidImages: false, raw: rawInfo, + edits: [], }, expect.any(String), ); }); }); + describe('handleAssetEditThumbnailGeneration', () => { + let rawInfo: RawImageInfo; + + beforeEach(() => { + rawInfo = { width: 100, height: 100, channels: 3 }; + mocks.person.getFaces.mockResolvedValue([]); + mocks.ocr.getByAssetId.mockResolvedValue([]); + mocks.media.decodeImage.mockImplementation((input) => + Promise.resolve( + typeof input === 'string' + ? { data: rawBuffer, info: rawInfo as OutputInfo } // string implies original file + : { data: fullsizeBuffer, info: rawInfo as OutputInfo }, // buffer implies embedded image extracted + ), + ); + }); + + it('should skip videos', async () => { + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video); + + await expect(sut.handleAssetEditThumbnailGeneration({ id: assetStub.video.id })).resolves.toBe(JobStatus.Success); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); + }); + + it('should upsert 3 edited files for edit jobs', async () => { + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ + ...assetStub.withCropEdit, + }); + const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); + mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); + mocks.person.getFaces.mockResolvedValue([]); + mocks.ocr.getByAssetId.mockResolvedValue([]); + + await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id }); + + expect(mocks.asset.upsertFiles).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ type: AssetFileType.FullSizeEdited }), + expect.objectContaining({ type: AssetFileType.PreviewEdited }), + expect.objectContaining({ type: AssetFileType.ThumbnailEdited }), + ]), + ); + }); + + it('should apply edits when generating thumbnails', async () => { + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ + ...assetStub.withCropEdit, + }); + mocks.person.getFaces.mockResolvedValue([]); + mocks.ocr.getByAssetId.mockResolvedValue([]); + + await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id }); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ + edits: [ + { + action: 'crop', + parameters: { height: 1152, width: 1512, x: 216, y: 1512 }, + }, + ], + }), + expect.any(String), + ); + }); + + it('should clean up edited files if an asset has no edits', async () => { + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ + ...assetStub.withoutEdits, + }); + + const status = await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id }); + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.FileDelete, + data: { + files: expect.arrayContaining([ + '/uploads/user-id/fullsize/path_edited.jpg', + '/uploads/user-id/preview/path_edited.jpg', + '/uploads/user-id/thumbnail/path_edited.jpg', + ]), + }, + }); + + expect(mocks.asset.deleteFiles).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ path: '/uploads/user-id/preview/path_edited.jpg' }), + expect.objectContaining({ path: '/uploads/user-id/thumbnail/path_edited.jpg' }), + expect.objectContaining({ path: '/uploads/user-id/fullsize/path_edited.jpg' }), + ]), + ); + + expect(status).toBe(JobStatus.Success); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.asset.upsertFiles).not.toHaveBeenCalled(); + }); + + it('should generate all 3 edited files if an asset has edits', async () => { + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ + ...assetStub.withCropEdit, + }); + mocks.person.getFaces.mockResolvedValue([]); + mocks.ocr.getByAssetId.mockResolvedValue([]); + + await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id }); + + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.anything(), + expect.stringContaining('edited_preview.jpeg'), + ); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.anything(), + expect.stringContaining('edited_thumbnail.webp'), + ); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.anything(), + expect.stringContaining('edited_fullsize.jpeg'), + ); + }); + + it('should generate the original thumbhash if no edits exist', async () => { + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ + ...assetStub.withoutEdits, + }); + const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); + mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); + + await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id, source: 'upload' }); + + expect(mocks.media.generateThumbhash).toHaveBeenCalled(); + }); + + it('should apply thumbhash if job source is edit and edits exist', async () => { + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ + ...assetStub.withCropEdit, + }); + const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); + mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); + mocks.person.getFaces.mockResolvedValue([]); + mocks.ocr.getByAssetId.mockResolvedValue([]); + + await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id }); + + expect(mocks.asset.update).toHaveBeenCalledWith( + expect.objectContaining({ + thumbhash: thumbhashBuffer, + }), + ); + }); + }); + describe('handleGeneratePersonThumbnail', () => { it('should skip if machine learning is disabled', async () => { mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); @@ -981,12 +1192,17 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, format: ImageFormat.Jpeg, quality: 80, - crop: { - left: 238, - top: 163, - width: 274, - height: 274, - }, + edits: [ + { + action: 'crop', + parameters: { + height: 274, + width: 274, + x: 238, + y: 163, + }, + }, + ], raw: info, processInvalidImages: false, size: 250, @@ -1020,12 +1236,17 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, format: ImageFormat.Jpeg, quality: 80, - crop: { - left: 238, - top: 163, - width: 274, - height: 274, - }, + edits: [ + { + action: 'crop', + parameters: { + height: 274, + width: 274, + x: 238, + y: 163, + }, + }, + ], raw: info, processInvalidImages: false, size: 250, @@ -1057,12 +1278,17 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, format: ImageFormat.Jpeg, quality: 80, - crop: { - left: 0, - top: 85, - width: 510, - height: 510, - }, + edits: [ + { + action: 'crop', + parameters: { + height: 510, + width: 510, + x: 0, + y: 85, + }, + }, + ], raw: info, processInvalidImages: false, size: 250, @@ -1094,12 +1320,17 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, format: ImageFormat.Jpeg, quality: 80, - crop: { - left: 591, - top: 591, - width: 408, - height: 408, - }, + edits: [ + { + action: 'crop', + parameters: { + height: 408, + width: 408, + x: 591, + y: 591, + }, + }, + ], raw: info, processInvalidImages: false, size: 250, @@ -1131,12 +1362,17 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, format: ImageFormat.Jpeg, quality: 80, - crop: { - left: 0, - top: 62, - width: 412, - height: 412, - }, + edits: [ + { + action: 'crop', + parameters: { + height: 412, + width: 412, + x: 0, + y: 62, + }, + }, + ], raw: info, processInvalidImages: false, size: 250, @@ -1168,12 +1404,17 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, format: ImageFormat.Jpeg, quality: 80, - crop: { - left: 4485, - top: 94, - width: 138, - height: 138, - }, + edits: [ + { + action: 'crop', + parameters: { + height: 138, + width: 138, + x: 4485, + y: 94, + }, + }, + ], raw: info, processInvalidImages: false, size: 250, @@ -1210,12 +1451,17 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, format: ImageFormat.Jpeg, quality: 80, - crop: { - height: 844, - left: 388, - top: 730, - width: 844, - }, + edits: [ + { + action: 'crop', + parameters: { + height: 844, + width: 844, + x: 388, + y: 730, + }, + }, + ], raw: info, processInvalidImages: false, size: 250, @@ -2999,4 +3245,147 @@ describe(MediaService.name, () => { expect(sut.isSRGB({ profileDescription: 'sRGB', bitsPerSample: 16 } as Exif)).toEqual(true); }); }); + + describe('syncFiles', () => { + it('should upsert new files when they do not exist', async () => { + const asset = { + id: 'asset-id', + files: [], + }; + + await sut['syncFiles'](asset, [ + { type: AssetFileType.Preview, newPath: '/new/preview.jpg' }, + { type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg' }, + ]); + + expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ + { assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview }, + { assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail }, + ]); + expect(mocks.asset.deleteFiles).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + }); + + it('should replace existing files with new paths', async () => { + const asset = { + id: 'asset-id', + files: [ + { id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' }, + { id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' }, + ], + }; + + await sut['syncFiles'](asset, [ + { type: AssetFileType.Preview, newPath: '/new/preview.jpg' }, + { type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg' }, + ]); + + expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ + { assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview }, + { assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail }, + ]); + expect(mocks.asset.deleteFiles).not.toHaveBeenCalled(); + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.FileDelete, + data: { files: ['/old/preview.jpg', '/old/thumbnail.jpg'] }, + }); + }); + + it('should delete files when newPath is not provided', async () => { + const asset = { + id: 'asset-id', + files: [ + { id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' }, + { id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' }, + ], + }; + + await sut['syncFiles'](asset, [{ type: AssetFileType.Preview }, { type: AssetFileType.Thumbnail }]); + + expect(mocks.asset.upsertFiles).not.toHaveBeenCalled(); + expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([ + { id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' }, + { id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' }, + ]); + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.FileDelete, + data: { files: ['/old/preview.jpg', '/old/thumbnail.jpg'] }, + }); + }); + + it('should not make changes when file paths already match', async () => { + const asset = { + id: 'asset-id', + files: [ + { id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/same/preview.jpg' }, + { id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/same/thumbnail.jpg' }, + ], + }; + + await sut['syncFiles'](asset, [ + { type: AssetFileType.Preview, newPath: '/same/preview.jpg' }, + { type: AssetFileType.Thumbnail, newPath: '/same/thumbnail.jpg' }, + ]); + + expect(mocks.asset.upsertFiles).not.toHaveBeenCalled(); + expect(mocks.asset.deleteFiles).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + }); + + it('should handle mixed operations (upsert, replace, delete)', async () => { + const asset = { + id: 'asset-id', + files: [ + { id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' }, + { id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' }, + ], + }; + + await sut['syncFiles'](asset, [ + { type: AssetFileType.Preview, newPath: '/new/preview.jpg' }, // replace + { type: AssetFileType.Thumbnail }, // delete + { type: AssetFileType.FullSize, newPath: '/new/fullsize.jpg' }, // new + ]); + + expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ + { assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview }, + { assetId: 'asset-id', path: '/new/fullsize.jpg', type: AssetFileType.FullSize }, + ]); + expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([ + { id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' }, + ]); + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.FileDelete, + data: { files: ['/old/preview.jpg', '/old/thumbnail.jpg'] }, + }); + }); + + it('should handle empty file list', async () => { + const asset = { + id: 'asset-id', + files: [], + }; + + await sut['syncFiles'](asset, []); + + expect(mocks.asset.upsertFiles).not.toHaveBeenCalled(); + expect(mocks.asset.deleteFiles).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + }); + + it('should delete non-existent file types when newPath is not provided', async () => { + const asset = { + id: 'asset-id', + files: [{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' }], + }; + + await sut['syncFiles'](asset, [ + { type: AssetFileType.Thumbnail }, // file doesn't exist, newPath not provided + ]); + + expect(mocks.asset.upsertFiles).not.toHaveBeenCalled(); + expect(mocks.asset.deleteFiles).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + }); + }); }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 917df1d8fd..f66cbbaa0b 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -1,8 +1,10 @@ import { Injectable } from '@nestjs/common'; +import { SystemConfig } from 'src/config'; import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core'; -import { Exif } from 'src/database'; +import { AssetFile, Exif } from 'src/database'; import { OnEvent, OnJob } from 'src/decorators'; +import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { AssetFileType, @@ -24,12 +26,13 @@ import { VideoCodec, VideoContainer, } from 'src/enum'; +import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { BoundingBox } from 'src/repositories/machine-learning.repository'; import { BaseService } from 'src/services/base.service'; import { AudioStreamInfo, - CropOptions, DecodeToBufferOptions, + GenerateThumbnailOptions, ImageDimensions, JobItem, JobOf, @@ -37,16 +40,20 @@ import { VideoInterfaces, VideoStreamInfo, } from 'src/types'; -import { getAssetFiles } from 'src/utils/asset.util'; +import { getAssetFiles, getDimensions } from 'src/utils/asset.util'; +import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor'; import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; import { mimeTypes } from 'src/utils/mime-types'; import { clamp, isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc'; +import { getOutputDimensions } from 'src/utils/transform'; interface UpsertFileOptions { assetId: string; type: AssetFileType; path: string; } +type ThumbnailAsset = NonNullable>>; + @Injectable() export class MediaService extends BaseService { videoInterfaces: VideoInterfaces = { dri: [], mali: false }; @@ -67,12 +74,19 @@ export class MediaService extends BaseService { }; for await (const asset of this.assetJobRepository.streamForThumbnailJob(!!force)) { - const { previewFile, thumbnailFile } = getAssetFiles(asset.files); + const assetFiles = getAssetFiles(asset.files); - if (!previewFile || !thumbnailFile || !asset.thumbhash || force) { + if (!assetFiles.previewFile || !assetFiles.thumbnailFile || !asset.thumbhash || force) { jobs.push({ name: JobName.AssetGenerateThumbnails, data: { id: asset.id } }); } + if ( + asset.edits.length > 0 && + (!assetFiles.editedPreviewFile || !assetFiles.editedThumbnailFile || !assetFiles.editedFullsizeFile || force) + ) { + jobs.push({ name: JobName.AssetEditThumbnailGeneration, data: { id: asset.id } }); + } + if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { await queueAll(); } @@ -154,9 +168,45 @@ export class MediaService extends BaseService { return JobStatus.Success; } + @OnJob({ name: JobName.AssetEditThumbnailGeneration, queue: QueueName.Editor }) + async handleAssetEditThumbnailGeneration({ id }: JobOf): Promise { + const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id); + + if (!asset) { + this.logger.warn(`Thumbnail generation failed for asset ${id}: not found in database or missing metadata`); + return JobStatus.Failed; + } + + const generated = await this.generateEditedThumbnails(asset); + + let thumbhash: Buffer | undefined = generated?.thumbhash; + if (!thumbhash) { + const { image } = await this.getConfig({ withCache: true }); + const extractedImage = await this.extractOriginalImage(asset, image); + const { info, data, colorspace } = extractedImage; + + thumbhash = await this.mediaRepository.generateThumbhash(data, { + colorspace, + processInvalidImages: false, + raw: info, + edits: [], + }); + } + + if (!asset.thumbhash || Buffer.compare(asset.thumbhash, thumbhash) !== 0) { + await this.assetRepository.update({ id: asset.id, thumbhash }); + } + + const fullsizeDimensions = generated?.fullsizeDimensions ?? getDimensions(asset.exifInfo!); + await this.assetRepository.update({ id: asset.id, ...fullsizeDimensions }); + + return JobStatus.Success; + } + @OnJob({ name: JobName.AssetGenerateThumbnails, queue: QueueName.ThumbnailGeneration }) async handleGenerateThumbnails({ id }: JobOf): Promise { const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id); + if (!asset) { this.logger.warn(`Thumbnail generation failed for asset ${id}: not found in database or missing metadata`); return JobStatus.Failed; @@ -172,6 +222,7 @@ export class MediaService extends BaseService { thumbnailPath: string; fullsizePath?: string; thumbhash: Buffer; + fullsizeDimensions?: ImageDimensions; }; if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) { this.logger.verbose(`Thumbnail generation for video ${id} ${asset.originalPath}`); @@ -184,54 +235,19 @@ export class MediaService extends BaseService { return JobStatus.Skipped; } - const { previewFile, thumbnailFile, fullsizeFile } = getAssetFiles(asset.files); - const toUpsert: UpsertFileOptions[] = []; - if (previewFile?.path !== generated.previewPath) { - toUpsert.push({ assetId: asset.id, path: generated.previewPath, type: AssetFileType.Preview }); - } + await this.syncFiles(asset, [ + { type: AssetFileType.Preview, newPath: generated.previewPath }, + { type: AssetFileType.Thumbnail, newPath: generated.thumbnailPath }, + { type: AssetFileType.FullSize, newPath: generated.fullsizePath }, + ]); - if (thumbnailFile?.path !== generated.thumbnailPath) { - toUpsert.push({ assetId: asset.id, path: generated.thumbnailPath, type: AssetFileType.Thumbnail }); - } + const editiedGenerated = await this.generateEditedThumbnails(asset); + const thumbhash = editiedGenerated?.thumbhash || generated.thumbhash; - if (generated.fullsizePath && fullsizeFile?.path !== generated.fullsizePath) { - toUpsert.push({ assetId: asset.id, path: generated.fullsizePath, type: AssetFileType.FullSize }); + if (!asset.thumbhash || Buffer.compare(asset.thumbhash, thumbhash) !== 0) { + await this.assetRepository.update({ id: asset.id, thumbhash }); } - if (toUpsert.length > 0) { - await this.assetRepository.upsertFiles(toUpsert); - } - - const pathsToDelete: string[] = []; - if (previewFile && previewFile.path !== generated.previewPath) { - this.logger.debug(`Deleting old preview for asset ${asset.id}`); - pathsToDelete.push(previewFile.path); - } - - if (thumbnailFile && thumbnailFile.path !== generated.thumbnailPath) { - this.logger.debug(`Deleting old thumbnail for asset ${asset.id}`); - pathsToDelete.push(thumbnailFile.path); - } - - if (fullsizeFile && fullsizeFile.path !== generated.fullsizePath) { - this.logger.debug(`Deleting old fullsize preview image for asset ${asset.id}`); - pathsToDelete.push(fullsizeFile.path); - if (!generated.fullsizePath) { - // did not generate a new fullsize image, delete the existing record - await this.assetRepository.deleteFiles([fullsizeFile]); - } - } - - if (pathsToDelete.length > 0) { - await Promise.all(pathsToDelete.map((path) => this.storageRepository.unlink(path))); - } - - if (!asset.thumbhash || Buffer.compare(asset.thumbhash, generated.thumbhash) !== 0) { - await this.assetRepository.update({ id: asset.id, thumbhash: generated.thumbhash }); - } - - await this.assetRepository.upsertJobStatus({ assetId: asset.id, previewAt: new Date(), thumbnailAt: new Date() }); - return JobStatus.Success; } @@ -258,27 +274,20 @@ export class MediaService extends BaseService { return { info, data, colorspace }; } - private async generateImageThumbnails(asset: { - id: string; - ownerId: string; - originalFileName: string; - originalPath: string; - exifInfo: Exif; - }) { - const { image } = await this.getConfig({ withCache: true }); - const previewPath = StorageCore.getImagePath(asset, AssetPathType.Preview, image.preview.format); - const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.Thumbnail, image.thumbnail.format); - this.storageCore.ensureFolders(previewPath); - - // Handle embedded preview extraction for RAW files + private async extractOriginalImage( + asset: NonNullable, + image: SystemConfig['image'], + useEdits = false, + ) { const extractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName); const extracted = extractEmbedded ? await this.extractImage(asset.originalPath, image.preview.size) : null; const generateFullsize = - (image.fullsize.enabled || asset.exifInfo.projectionType == 'EQUIRECTANGULAR') && - !mimeTypes.isWebSupportedImage(asset.originalPath); + ((image.fullsize.enabled || asset.exifInfo.projectionType === 'EQUIRECTANGULAR') && + !mimeTypes.isWebSupportedImage(asset.originalPath)) || + useEdits; const convertFullsize = generateFullsize && (!extracted || !mimeTypes.isWebSupportedImage(` .${extracted.format}`)); - const { info, data, colorspace } = await this.decodeImage( + const { data, info, colorspace } = await this.decodeImage( extracted ? extracted.buffer : asset.originalPath, // only specify orientation to extracted images which don't have EXIF orientation data // or it can double rotate the image @@ -286,20 +295,64 @@ export class MediaService extends BaseService { convertFullsize ? undefined : image.preview.size, ); + return { + extracted, + data, + info, + colorspace, + convertFullsize, + generateFullsize, + }; + } + + private async generateImageThumbnails(asset: ThumbnailAsset, useEdits: boolean = false) { + const { image } = await this.getConfig({ withCache: true }); + const previewPath = StorageCore.getImagePath( + asset, + useEdits ? AssetPathType.EditedPreview : AssetPathType.Preview, + image.preview.format, + ); + const thumbnailPath = StorageCore.getImagePath( + asset, + useEdits ? AssetPathType.EditedThumbnail : AssetPathType.Thumbnail, + image.thumbnail.format, + ); + this.storageCore.ensureFolders(previewPath); + + // Handle embedded preview extraction for RAW files + const extractedImage = await this.extractOriginalImage(asset, image, useEdits); + const { info, data, colorspace, generateFullsize, convertFullsize, extracted } = extractedImage; + // generate final images - const thumbnailOptions = { colorspace, processInvalidImages: false, raw: info }; + const thumbnailOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] }; const promises = [ this.mediaRepository.generateThumbhash(data, thumbnailOptions), - this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...thumbnailOptions }, thumbnailPath), - this.mediaRepository.generateThumbnail(data, { ...image.preview, ...thumbnailOptions }, previewPath), + this.mediaRepository.generateThumbnail( + data, + { ...image.thumbnail, ...thumbnailOptions, edits: useEdits ? asset.edits : [] }, + thumbnailPath, + ), + this.mediaRepository.generateThumbnail( + data, + { ...image.preview, ...thumbnailOptions, edits: useEdits ? asset.edits : [] }, + previewPath, + ), ]; let fullsizePath: string | undefined; if (convertFullsize) { // convert a new fullsize image from the same source as the thumbnail - fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FullSize, image.fullsize.format); - const fullsizeOptions = { format: image.fullsize.format, quality: image.fullsize.quality, ...thumbnailOptions }; + fullsizePath = StorageCore.getImagePath( + asset, + useEdits ? AssetPathType.EditedFullSize : AssetPathType.FullSize, + image.fullsize.format, + ); + const fullsizeOptions = { + format: image.fullsize.format, + quality: image.fullsize.quality, + ...thumbnailOptions, + }; promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath)); } else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) { fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FullSize, extracted.format); @@ -328,7 +381,10 @@ export class MediaService extends BaseService { await Promise.all(promises); } - return { previewPath, thumbnailPath, fullsizePath, thumbhash: outputs[0] as Buffer }; + const decodedDimensions = { width: info.width, height: info.height }; + const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions; + + return { previewPath, thumbnailPath, fullsizePath, thumbhash: outputs[0] as Buffer, fullsizeDimensions }; } @OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration }) @@ -369,17 +425,22 @@ export class MediaService extends BaseService { const thumbnailPath = StorageCore.getPersonThumbnailPath({ id, ownerId }); this.storageCore.ensureFolders(thumbnailPath); - const thumbnailOptions = { + const thumbnailOptions: GenerateThumbnailOptions = { colorspace: image.colorspace, format: ImageFormat.Jpeg, raw: info, quality: image.thumbnail.quality, - crop: this.getCrop( - { old: { width: oldWidth, height: oldHeight }, new: { width: info.width, height: info.height } }, - { x1, y1, x2, y2 }, - ), processInvalidImages: false, size: FACE_THUMBNAIL_SIZE, + edits: [ + { + action: AssetEditAction.Crop, + parameters: this.getCrop( + { old: { width: oldWidth, height: oldHeight }, new: { width: info.width, height: info.height } }, + { x1, y1, x2, y2 }, + ), + }, + ], }; await this.mediaRepository.generateThumbnail(decodedImage, thumbnailOptions, thumbnailPath); @@ -388,7 +449,10 @@ export class MediaService extends BaseService { return JobStatus.Success; } - private getCrop(dims: { old: ImageDimensions; new: ImageDimensions }, { x1, y1, x2, y2 }: BoundingBox): CropOptions { + private getCrop( + dims: { old: ImageDimensions; new: ImageDimensions }, + { x1, y1, x2, y2 }: BoundingBox, + ): CropParameters { // face bounding boxes can spill outside the image dimensions const clampedX1 = clamp(x1, 0, dims.old.width); const clampedY1 = clamp(y1, 0, dims.old.height); @@ -416,8 +480,8 @@ export class MediaService extends BaseService { ); return { - left: middleX - newHalfSize, - top: middleY - newHalfSize, + x: middleX - newHalfSize, + y: middleY - newHalfSize, width: newHalfSize * 2, height: newHalfSize * 2, }; @@ -454,7 +518,12 @@ export class MediaService extends BaseService { processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', }); - return { previewPath, thumbnailPath, thumbhash }; + return { + previewPath, + thumbnailPath, + thumbhash, + fullsizeDimensions: { width: mainVideoStream.width, height: mainVideoStream.height }, + }; } @OnJob({ name: JobName.AssetEncodeVideoQueueAll, queue: QueueName.VideoConversion }) @@ -707,4 +776,84 @@ export class MediaService extends BaseService { return false; } } + + private async syncFiles( + asset: { id: string; files: AssetFile[] }, + files: { type: AssetFileType; newPath?: string }[], + ) { + const toUpsert: UpsertFileOptions[] = []; + const pathsToDelete: string[] = []; + const toDelete: AssetFile[] = []; + + for (const { type, newPath } of files) { + const existingFile = asset.files.find((file) => file.type === type); + + // upsert new file path + if (newPath && existingFile?.path !== newPath) { + toUpsert.push({ assetId: asset.id, path: newPath, type }); + + // delete old file from disk + if (existingFile) { + this.logger.debug(`Deleting old ${type} image for asset ${asset.id} in favor of a replacement`); + pathsToDelete.push(existingFile.path); + } + } + + // delete old file from disk and database + if (!newPath && existingFile) { + this.logger.debug(`Deleting old ${type} image for asset ${asset.id}`); + + pathsToDelete.push(existingFile.path); + toDelete.push(existingFile); + } + } + + if (toUpsert.length > 0) { + await this.assetRepository.upsertFiles(toUpsert); + } + + if (toDelete.length > 0) { + await this.assetRepository.deleteFiles(toDelete); + } + + if (pathsToDelete.length > 0) { + await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: pathsToDelete } }); + } + } + + private async generateEditedThumbnails(asset: ThumbnailAsset) { + if (asset.type !== AssetType.Image) { + return; + } + + const generated = asset.edits.length > 0 ? await this.generateImageThumbnails(asset, true) : undefined; + + await this.syncFiles(asset, [ + { type: AssetFileType.PreviewEdited, newPath: generated?.previewPath }, + { type: AssetFileType.ThumbnailEdited, newPath: generated?.thumbnailPath }, + { type: AssetFileType.FullSizeEdited, newPath: generated?.fullsizePath }, + ]); + + const crop = asset.edits.find((e) => e.action === AssetEditAction.Crop); + const cropBox = crop + ? { + x1: crop.parameters.x, + y1: crop.parameters.y, + x2: crop.parameters.x + crop.parameters.width, + y2: crop.parameters.y + crop.parameters.height, + } + : undefined; + + const originalDimensions = getDimensions(asset.exifInfo!); + const assetFaces = await this.personRepository.getFaces(asset.id, {}); + const ocrData = await this.ocrRepository.getByAssetId(asset.id, {}); + + const faceStatuses = checkFaceVisibility(assetFaces, originalDimensions, cropBox); + await this.personRepository.updateVisibility(faceStatuses.visible, faceStatuses.hidden); + + const ocrStatuses = checkOcrVisibility(ocrData, originalDimensions, cropBox); + await this.ocrRepository.updateOcrVisibilities(asset.id, ocrStatuses.visible, ocrStatuses.hidden); + + return generated; + } } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 98c906d9c7..e6d6a523b1 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -224,6 +224,8 @@ describe(MetadataService.name, () => { fileCreatedAt: fileModifiedAt, fileModifiedAt, localDateTime: fileModifiedAt, + width: null, + height: null, }); }); @@ -251,6 +253,8 @@ describe(MetadataService.name, () => { fileCreatedAt, fileModifiedAt, localDateTime: fileCreatedAt, + width: null, + height: null, }); }); @@ -297,6 +301,8 @@ describe(MetadataService.name, () => { fileCreatedAt: assetStub.image.fileCreatedAt, fileModifiedAt: assetStub.image.fileCreatedAt, localDateTime: assetStub.image.fileCreatedAt, + width: null, + height: null, }); }); @@ -327,6 +333,8 @@ describe(MetadataService.name, () => { fileCreatedAt: assetStub.withLocation.fileCreatedAt, fileModifiedAt: assetStub.withLocation.fileModifiedAt, localDateTime: new Date('2023-02-22T05:06:29.716Z'), + width: null, + height: null, }); }); @@ -357,6 +365,8 @@ describe(MetadataService.name, () => { fileCreatedAt: assetStub.withLocation.fileCreatedAt, fileModifiedAt: assetStub.withLocation.fileModifiedAt, localDateTime: new Date('2023-02-22T05:06:29.716Z'), + width: null, + height: null, }); }); @@ -1560,6 +1570,49 @@ describe(MetadataService.name, () => { { lockedPropertiesBehavior: 'skip' }, ); }); + + it('should properly set width/height for normal images', async () => { + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + mockReadTags({ ImageWidth: 1000, ImageHeight: 2000 }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(mocks.asset.update).toHaveBeenCalledWith( + expect.objectContaining({ + width: 1000, + height: 2000, + }), + ); + }); + + it('should properly swap asset width/height for rotated images', async () => { + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + mockReadTags({ ImageWidth: 1000, ImageHeight: 2000, Orientation: 6 }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(mocks.asset.update).toHaveBeenCalledWith( + expect.objectContaining({ + width: 2000, + height: 1000, + }), + ); + }); + + it('should not overwrite existing width/height if they already exist', async () => { + mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ + ...assetStub.image, + width: 1920, + height: 1080, + }); + mockReadTags({ ImageWidth: 1280, ImageHeight: 720 }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(mocks.asset.update).not.toHaveBeenCalledWith( + expect.objectContaining({ + width: 1280, + height: 720, + }), + ); + }); }); describe('handleQueueSidecar', () => { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 3e5b220c04..c9535f3612 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -196,6 +196,15 @@ export class MetadataService extends BaseService { await this.eventRepository.emit('AssetHide', { assetId: motionAsset.id, userId: motionAsset.ownerId }); } + private isOrientationSidewards(orientation: ExifOrientation | number): boolean { + return [ + ExifOrientation.MirrorHorizontalRotate270CW, + ExifOrientation.Rotate90CW, + ExifOrientation.MirrorHorizontalRotate90CW, + ExifOrientation.Rotate270CW, + ].includes(orientation); + } + @OnJob({ name: JobName.AssetExtractMetadataQueueAll, queue: QueueName.MetadataExtraction }) async handleQueueMetadataExtraction(job: JobOf): Promise { const { force } = job; @@ -289,6 +298,10 @@ export class MetadataService extends BaseService { autoStackId: this.getAutoStackId(exifTags), }; + const isSidewards = exifTags.Orientation && this.isOrientationSidewards(exifTags.Orientation); + const assetWidth = isSidewards ? validate(height) : validate(width); + const assetHeight = isSidewards ? validate(width) : validate(height); + const promises: Promise[] = [ this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' }), this.assetRepository.update({ @@ -297,6 +310,11 @@ export class MetadataService extends BaseService { localDateTime: dates.localDateTime, fileCreatedAt: dates.dateTimeOriginal ?? undefined, fileModifiedAt: stats.mtime, + + // only update the dimensions if they don't already exist + // we don't want to overwrite width/height that are modified by edits + width: asset.width == null ? assetWidth : undefined, + height: asset.height == null ? assetHeight : undefined, }), this.applyTagList(asset, exifTags), ]; @@ -716,12 +734,7 @@ export class MetadataService extends BaseService { return regionInfo; } - const isSidewards = [ - ExifOrientation.MirrorHorizontalRotate270CW, - ExifOrientation.Rotate90CW, - ExifOrientation.MirrorHorizontalRotate90CW, - ExifOrientation.Rotate270CW, - ].includes(orientation); + const isSidewards = this.isOrientationSidewards(orientation); // swap image dimensions in AppliedToDimensions if orientation is sidewards const adjustedAppliedToDimensions = isSidewards @@ -971,9 +984,17 @@ export class MetadataService extends BaseService { private async getVideoTags(originalPath: string) { const { videoStreams, format } = await this.mediaRepository.probe(originalPath); - const tags: Pick = {}; + const tags: Pick = {}; if (videoStreams[0]) { + // Set video dimensions + if (videoStreams[0].width) { + tags.ImageWidth = videoStreams[0].width; + } + if (videoStreams[0].height) { + tags.ImageHeight = videoStreams[0].height; + } + switch (videoStreams[0].rotation) { case -90: { tags.Orientation = ExifOrientation.Rotate90CW; diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 41c44ea476..b57a5e1072 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -354,6 +354,7 @@ describe(PersonService.name, () => { it('should get the bounding boxes for an asset', async () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([faceStub.face1.assetId])); mocks.person.getFaces.mockResolvedValue([faceStub.primaryFace1]); + mocks.asset.getById.mockResolvedValue(assetStub.image); await expect(sut.getFacesById(authStub.admin, { id: faceStub.face1.assetId })).resolves.toStrictEqual([ mapFaces(faceStub.primaryFace1, authStub.admin), ]); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 6fa9b3fdd2..dfbb56bd1e 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -40,6 +40,7 @@ import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table'; import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; +import { getDimensions } from 'src/utils/asset.util'; import { ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { isFacialRecognitionEnabled } from 'src/utils/misc'; @@ -126,7 +127,10 @@ export class PersonService extends BaseService { async getFacesById(auth: AuthDto, dto: FaceDto): Promise { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.id] }); const faces = await this.personRepository.getFaces(dto.id); - return faces.map((asset) => mapFaces(asset, auth)); + const asset = await this.assetRepository.getById(dto.id, { edits: true, exifInfo: true }); + const assetDimensions = getDimensions(asset!.exifInfo!); + + return faces.map((face) => mapFaces(face, auth, asset!.edits!, assetDimensions)); } async createNewFeaturePhoto(changeFeaturePhoto: string[]) { diff --git a/server/src/services/queue.service.spec.ts b/server/src/services/queue.service.spec.ts index f5cf20413e..2c76fee877 100644 --- a/server/src/services/queue.service.spec.ts +++ b/server/src/services/queue.service.spec.ts @@ -23,7 +23,7 @@ describe(QueueService.name, () => { it('should update concurrency', () => { sut.onConfigUpdate({ newConfig: defaults, oldConfig: {} as SystemConfig }); - expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(17); + expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(18); expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FacialRecognition, 1); expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DuplicateDetection, 1); expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BackgroundTask, 5); @@ -77,6 +77,7 @@ describe(QueueService.name, () => { [QueueName.BackupDatabase]: expected, [QueueName.Ocr]: expected, [QueueName.Workflow]: expected, + [QueueName.Editor]: expected, }); }); }); diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index fbdd655bbc..fdeabd3a90 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -41,6 +41,7 @@ const updatedConfig = Object.freeze({ [QueueName.Notification]: { concurrency: 5 }, [QueueName.Ocr]: { concurrency: 1 }, [QueueName.Workflow]: { concurrency: 5 }, + [QueueName.Editor]: { concurrency: 2 }, }, backup: { database: { diff --git a/server/src/types.ts b/server/src/types.ts index cc20bccc34..c28330a55e 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -3,6 +3,7 @@ import { VECTOR_EXTENSIONS } from 'src/constants'; import { Asset, AssetFile } from 'src/database'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { SetMaintenanceModeDto } from 'src/dtos/maintenance.dto'; import { AssetOrder, @@ -26,13 +27,6 @@ export type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial = Pick; -export interface CropOptions { - top: number; - left: number; - width: number; - height: number; -} - export interface FullsizeImageOptions { format: ImageFormat; quality: number; @@ -53,9 +47,9 @@ export interface RawImageInfo { interface DecodeImageOptions { colorspace: string; - crop?: CropOptions; processInvalidImages: boolean; raw?: RawImageInfo; + edits?: AssetEditActionItem[]; } export interface DecodeToBufferOptions extends DecodeImageOptions { @@ -73,7 +67,6 @@ export type GenerateThumbhashFromBufferOptions = GenerateThumbhashOptions & { ra export interface GenerateThumbnailsOptions { colorspace: string; - crop?: CropOptions; preview?: ImageOptions; processInvalidImages: boolean; thumbhash?: boolean; @@ -187,7 +180,7 @@ export interface IDelayedJob extends IBaseJob { delay?: number; } -export type JobSource = 'upload' | 'sidecar-write' | 'copy'; +export type JobSource = 'upload' | 'sidecar-write' | 'copy' | 'edit'; export interface IEntityJob extends IBaseJob { id: string; source?: JobSource; @@ -386,7 +379,10 @@ export type JobItem = | { name: JobName.Ocr; data: IEntityJob } // Workflow - | { name: JobName.WorkflowRun; data: IWorkflowJob }; + | { name: JobName.WorkflowRun; data: IWorkflowJob } + + // Editor + | { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob }; export type VectorExtension = (typeof VECTOR_EXTENSIONS)[number]; diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index f8d5f0ca08..7431cb3293 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -157,6 +157,18 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); } + case Permission.AssetEditGet: { + return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + } + + case Permission.AssetEditCreate: { + return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + } + + case Permission.AssetEditDelete: { + return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + } + case Permission.AlbumRead: { const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); const isShared = await access.album.checkSharedAlbumAccess( diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index f3f807c829..94f7f231a8 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -1,9 +1,10 @@ import { BadRequestException } from '@nestjs/common'; import { GeneratedImageType, StorageCore } from 'src/cores/storage.core'; -import { AssetFile } from 'src/database'; +import { AssetFile, Exif } from 'src/database'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { ExifResponseDto } from 'src/dtos/exif.dto'; import { AssetFileType, AssetType, AssetVisibility, Permission } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { AccessRepository } from 'src/repositories/access.repository'; @@ -22,6 +23,10 @@ export const getAssetFiles = (files: AssetFile[]) => ({ previewFile: getAssetFile(files, AssetFileType.Preview), thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail), sidecarFile: getAssetFile(files, AssetFileType.Sidecar), + + editedFullsizeFile: getAssetFile(files, AssetFileType.FullSizeEdited), + editedPreviewFile: getAssetFile(files, AssetFileType.PreviewEdited), + editedThumbnailFile: getAssetFile(files, AssetFileType.ThumbnailEdited), }); export const addAssets = async ( @@ -199,3 +204,26 @@ export const asUploadRequest = (request: AuthRequest, file: Express.Multer.File) file: mapToUploadFile(file as ImmichFile), }; }; + +const isFlipped = (orientation?: string | null) => { + const value = Number(orientation); + return value && [5, 6, 7, 8, -90, 90].includes(value); +}; + +export const getDimensions = (exifInfo: ExifResponseDto | Exif) => { + const { exifImageWidth: width, exifImageHeight: height } = exifInfo; + + if (!width || !height) { + return { width: 0, height: 0 }; + } + + if (isFlipped(exifInfo.orientation)) { + return { width: height, height: width }; + } + + return { width, height }; +}; + +export const isPanorama = (asset: { exifInfo?: Exif | null; originalFileName: string }) => { + return asset.exifInfo?.projectionType === 'EQUIRECTANGULAR' || asset.originalFileName.toLowerCase().endsWith('.insp'); +}; diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 95998eb44b..a041946a28 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -1,4 +1,5 @@ import { + AliasedRawBuilder, DeduplicateJoinsPlugin, Expression, ExpressionBuilder, @@ -16,6 +17,7 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { parse } from 'pg-connection-string'; import postgres, { Notice, PostgresError } from 'postgres'; import { columns, Exif, lockableProperties, LockableProperty, Person } from 'src/database'; +import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { AssetFileType, AssetVisibility, DatabaseExtension, DatabaseSslMode } from 'src/enum'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; import { DB } from 'src/schema'; @@ -180,13 +182,14 @@ export function withSmartSearch(qb: SelectQueryBuilder) { .select((eb) => toJson(eb, 'smart_search').as('smartSearch')); } -export function withFaces(eb: ExpressionBuilder, withDeletedFace?: boolean) { +export function withFaces(eb: ExpressionBuilder, withHidden?: boolean, withDeletedFace?: boolean) { return jsonArrayFrom( eb .selectFrom('asset_face') .selectAll('asset_face') .whereRef('asset_face.assetId', '=', 'asset.id') - .$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null)), + .$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null)) + .$if(!withHidden, (qb) => qb.where('asset_face.isVisible', '=', true)), ).as('faces'); } @@ -208,7 +211,11 @@ export function withFilePath(eb: ExpressionBuilder, type: AssetFile .where('asset_file.type', '=', type); } -export function withFacesAndPeople(eb: ExpressionBuilder, withDeletedFace?: boolean) { +export function withFacesAndPeople( + eb: ExpressionBuilder, + withHidden?: boolean, + withDeletedFace?: boolean, +) { return jsonArrayFrom( eb .selectFrom('asset_face') @@ -220,7 +227,8 @@ export function withFacesAndPeople(eb: ExpressionBuilder, withDelet .selectAll('asset_face') .select((eb) => eb.table('person').$castTo().as('person')) .whereRef('asset_face.assetId', '=', 'asset.id') - .$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null)), + .$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null)) + .$if(!withHidden, (qb) => qb.where('asset_face.isVisible', 'is', true)), ).as('faces'); } @@ -232,6 +240,7 @@ export function hasPeople(qb: SelectQueryBuilder, personIds: .select('assetId') .where('personId', '=', anyUuid(personIds!)) .where('deletedAt', 'is', null) + .where('isVisible', 'is', true) .groupBy('assetId') .having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length) .as('has_people'), @@ -346,6 +355,17 @@ export const tokenizeForSearch = (text: string): string[] => { return tokens; }; +// needed to properly type the return with the EditActionItem discriminated union type +type AliasedEditActions = AliasedRawBuilder; +export function withEdits(eb: ExpressionBuilder): AliasedEditActions { + return jsonArrayFrom( + eb + .selectFrom('asset_edit') + .select(['asset_edit.action', 'asset_edit.parameters']) + .whereRef('asset_edit.assetId', '=', 'asset.id'), + ).as('edits') as AliasedEditActions; +} + const joinDeduplicationPlugin = new DeduplicateJoinsPlugin(); /** TODO: This should only be used for search-related queries, not as a general purpose query builder */ diff --git a/server/src/utils/editor.spec.ts b/server/src/utils/editor.spec.ts new file mode 100644 index 0000000000..17db0d9da3 --- /dev/null +++ b/server/src/utils/editor.spec.ts @@ -0,0 +1,505 @@ +import { AssetFace } from 'src/database'; +import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; +import { SourceType } from 'src/enum'; +import { boundingBoxOverlap, checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor'; +import { describe, expect, it } from 'vitest'; + +describe('boundingBoxOverlap', () => { + it('should return 1 for identical boxes', () => { + const box = { x1: 0, y1: 0, x2: 100, y2: 100 }; + expect(boundingBoxOverlap(box, box)).toBe(1); + }); + + it('should return 0 for non-overlapping boxes', () => { + const boxA = { x1: 0, y1: 0, x2: 100, y2: 100 }; + const boxB = { x1: 200, y1: 200, x2: 300, y2: 300 }; + expect(boundingBoxOverlap(boxA, boxB)).toBe(0); + }); + + it('should return 0.5 for 50% overlap', () => { + const boxA = { x1: 0, y1: 0, x2: 100, y2: 100 }; + const boxB = { x1: 50, y1: 0, x2: 150, y2: 100 }; + expect(boundingBoxOverlap(boxA, boxB)).toBe(0.5); + }); + + it('should return 0.25 for 25% overlap', () => { + const boxA = { x1: 0, y1: 0, x2: 100, y2: 100 }; + const boxB = { x1: 50, y1: 50, x2: 150, y2: 150 }; + expect(boundingBoxOverlap(boxA, boxB)).toBe(0.25); + }); + + it('should return 1 when boxA is fully contained in boxB', () => { + const boxA = { x1: 25, y1: 25, x2: 75, y2: 75 }; + const boxB = { x1: 0, y1: 0, x2: 100, y2: 100 }; + expect(boundingBoxOverlap(boxA, boxB)).toBe(1); + }); + + it('should handle partial containment correctly', () => { + const boxA = { x1: 0, y1: 0, x2: 100, y2: 100 }; + const boxB = { x1: 25, y1: 25, x2: 75, y2: 75 }; + // boxB is fully inside boxA, so overlap area is 50*50=2500, boxA area is 10000 + expect(boundingBoxOverlap(boxA, boxB)).toBe(0.25); + }); + + it('should handle boxes that touch at edges (no overlap)', () => { + const boxA = { x1: 0, y1: 0, x2: 100, y2: 100 }; + const boxB = { x1: 100, y1: 0, x2: 200, y2: 100 }; + expect(boundingBoxOverlap(boxA, boxB)).toBe(0); + }); + + it('should handle vertical partial overlap', () => { + const boxA = { x1: 0, y1: 0, x2: 100, y2: 100 }; + const boxB = { x1: 0, y1: 50, x2: 100, y2: 150 }; + expect(boundingBoxOverlap(boxA, boxB)).toBe(0.5); + }); +}); + +const createFace = (params: Partial = {}): AssetFace => ({ + id: 'face-id', + deletedAt: null, + assetId: 'asset-id', + boundingBoxX1: 100, + boundingBoxX2: 200, + boundingBoxY1: 100, + boundingBoxY2: 200, + imageWidth: 1000, + imageHeight: 1000, + personId: null, + sourceType: SourceType.MachineLearning, + person: null, + updatedAt: new Date(), + updateId: 'update-id', + isVisible: true, + ...params, +}); + +describe('checkFaceVisibility', () => { + const assetDimensions = { width: 1000, height: 1000 }; + + it('should return only non-visible faces when no crop is provided', () => { + const faces = [ + createFace({ id: 'face-1', isVisible: true }), + createFace({ id: 'face-2', isVisible: false }), + createFace({ id: 'face-3', isVisible: false }), + ]; + const result = checkFaceVisibility(faces, assetDimensions); + + expect(result.visible).toHaveLength(2); + expect(result.hidden).toHaveLength(0); + expect(result.visible.map((f) => f.id)).toEqual(['face-2', 'face-3']); + }); + + it('should return all faces as visible when all are marked not visible and no crop provided', () => { + const faces = [createFace({ id: 'face-1', isVisible: false }), createFace({ id: 'face-2', isVisible: false })]; + const result = checkFaceVisibility(faces, assetDimensions); + + expect(result.visible).toHaveLength(2); + expect(result.hidden).toHaveLength(0); + }); + + it('should return empty visible array when all faces are already visible and no crop provided', () => { + const faces = [createFace({ id: 'face-1', isVisible: true }), createFace({ id: 'face-2', isVisible: true })]; + const result = checkFaceVisibility(faces, assetDimensions); + + expect(result.visible).toHaveLength(0); + expect(result.hidden).toHaveLength(0); + }); + + it('should return empty arrays when no faces provided', () => { + const result = checkFaceVisibility([], assetDimensions); + + expect(result.visible).toHaveLength(0); + expect(result.hidden).toHaveLength(0); + }); + + it('should mark face as visible when fully inside crop area', () => { + const faces = [createFace({ boundingBoxX1: 100, boundingBoxY1: 100, boundingBoxX2: 200, boundingBoxY2: 200 })]; + const crop = { x1: 0, y1: 0, x2: 500, y2: 500 }; + + const result = checkFaceVisibility(faces, assetDimensions, crop); + + expect(result.visible).toHaveLength(1); + expect(result.hidden).toHaveLength(0); + }); + + it('should mark face as hidden when fully outside crop area', () => { + const faces = [createFace({ boundingBoxX1: 600, boundingBoxY1: 600, boundingBoxX2: 700, boundingBoxY2: 700 })]; + const crop = { x1: 0, y1: 0, x2: 500, y2: 500 }; + + const result = checkFaceVisibility(faces, assetDimensions, crop); + + expect(result.visible).toHaveLength(0); + expect(result.hidden).toHaveLength(1); + }); + + it('should mark face as visible when at least 50% overlaps with crop', () => { + // Face spans 100-200 (100px), crop starts at 150, so 50% overlap + const faces = [createFace({ boundingBoxX1: 100, boundingBoxY1: 100, boundingBoxX2: 200, boundingBoxY2: 200 })]; + const crop = { x1: 150, y1: 100, x2: 500, y2: 500 }; + + const result = checkFaceVisibility(faces, assetDimensions, crop); + + expect(result.visible).toHaveLength(1); + expect(result.hidden).toHaveLength(0); + }); + + it('should mark face as hidden when less than 50% overlaps with crop', () => { + // Face spans 100-200 (100px), crop starts at 160, so 40% overlap + const faces = [createFace({ boundingBoxX1: 100, boundingBoxY1: 100, boundingBoxX2: 200, boundingBoxY2: 200 })]; + const crop = { x1: 160, y1: 100, x2: 500, y2: 500 }; + + const result = checkFaceVisibility(faces, assetDimensions, crop); + + expect(result.visible).toHaveLength(0); + expect(result.hidden).toHaveLength(1); + }); + + it('should correctly categorize multiple faces', () => { + const faces = [ + createFace({ id: 'face-inside', boundingBoxX1: 100, boundingBoxY1: 100, boundingBoxX2: 200, boundingBoxY2: 200 }), + createFace({ + id: 'face-outside', + boundingBoxX1: 800, + boundingBoxY1: 800, + boundingBoxX2: 900, + boundingBoxY2: 900, + }), + // face-partial: 400-500 overlaps with crop (100x100=10000 overlap, face is 200x200=40000, so 25% - hidden) + createFace({ + id: 'face-partial', + boundingBoxX1: 400, + boundingBoxY1: 400, + boundingBoxX2: 600, + boundingBoxY2: 600, + }), + ]; + const crop = { x1: 0, y1: 0, x2: 500, y2: 500 }; + + const result = checkFaceVisibility(faces, assetDimensions, crop); + + // face-inside is fully visible, face-partial has 25% overlap (hidden), face-outside is fully hidden + expect(result.visible).toHaveLength(1); + expect(result.hidden).toHaveLength(2); + expect(result.visible.map((f) => f.id)).toContain('face-inside'); + expect(result.hidden.map((f) => f.id)).toContain('face-partial'); + expect(result.hidden.map((f) => f.id)).toContain('face-outside'); + }); + + it('should handle face coordinates scaled to different image dimensions', () => { + // Face stored at 50-100 in a 500x500 image, scaled to 1000x1000 becomes 100-200 + const faces = [ + createFace({ + boundingBoxX1: 50, + boundingBoxY1: 50, + boundingBoxX2: 100, + boundingBoxY2: 100, + imageWidth: 500, + imageHeight: 500, + }), + ]; + const crop = { x1: 0, y1: 0, x2: 200, y2: 200 }; + + const result = checkFaceVisibility(faces, assetDimensions, crop); + + expect(result.visible).toHaveLength(1); + expect(result.hidden).toHaveLength(0); + }); + + it('should categorize based on crop overlap when crop is provided, regardless of isVisible property', () => { + const faces = [ + createFace({ + id: 'face-inside-visible', + boundingBoxX1: 100, + boundingBoxY1: 100, + boundingBoxX2: 200, + boundingBoxY2: 200, + isVisible: true, + }), + createFace({ + id: 'face-inside-not-visible', + boundingBoxX1: 250, + boundingBoxY1: 250, + boundingBoxX2: 350, + boundingBoxY2: 350, + isVisible: false, + }), + createFace({ + id: 'face-outside-visible', + boundingBoxX1: 800, + boundingBoxY1: 800, + boundingBoxX2: 900, + boundingBoxY2: 900, + isVisible: true, + }), + createFace({ + id: 'face-outside-not-visible', + boundingBoxX1: 700, + boundingBoxY1: 700, + boundingBoxX2: 800, + boundingBoxY2: 800, + isVisible: false, + }), + ]; + const crop = { x1: 0, y1: 0, x2: 500, y2: 500 }; + + const result = checkFaceVisibility(faces, assetDimensions, crop); + + // When crop is provided, only overlap matters, not isVisible property + expect(result.visible).toHaveLength(2); + expect(result.hidden).toHaveLength(2); + expect(result.visible.map((f) => f.id)).toContain('face-inside-visible'); + expect(result.visible.map((f) => f.id)).toContain('face-inside-not-visible'); + expect(result.hidden.map((f) => f.id)).toContain('face-outside-visible'); + expect(result.hidden.map((f) => f.id)).toContain('face-outside-not-visible'); + }); + + it('should handle mixed visibility states with partial overlap and crop', () => { + const faces = [ + createFace({ + id: 'face-partial-50', + boundingBoxX1: 100, + boundingBoxY1: 100, + boundingBoxX2: 200, + boundingBoxY2: 200, + isVisible: true, + }), + createFace({ + id: 'face-partial-40', + boundingBoxX1: 100, + boundingBoxY1: 100, + boundingBoxX2: 200, + boundingBoxY2: 200, + isVisible: false, + }), + ]; + const crop1 = { x1: 150, y1: 100, x2: 500, y2: 500 }; // 50% overlap + const crop2 = { x1: 160, y1: 100, x2: 500, y2: 500 }; // 40% overlap + + const result1 = checkFaceVisibility([faces[0]], assetDimensions, crop1); + const result2 = checkFaceVisibility([faces[1]], assetDimensions, crop2); + + // 50% overlap should be visible + expect(result1.visible).toHaveLength(1); + expect(result1.hidden).toHaveLength(0); + + // 40% overlap should be hidden + expect(result2.visible).toHaveLength(0); + expect(result2.hidden).toHaveLength(1); + }); +}); + +const createOcr = ( + params: Partial = {}, +): AssetOcrResponseDto & { isVisible: boolean } => ({ + id: 'ocr-id', + assetId: 'asset-id', + x1: 0.1, + y1: 0.1, + x2: 0.2, + y2: 0.1, + x3: 0.2, + y3: 0.2, + x4: 0.1, + y4: 0.2, + boxScore: 0.9, + textScore: 0.9, + text: 'Sample Text', + isVisible: true, + ...params, +}); + +describe('checkOcrVisibility', () => { + const assetDimensions = { width: 1000, height: 1000 }; + + it('should return only non-visible OCR entries when no crop is provided', () => { + const ocrs = [ + createOcr({ id: 'ocr-1', isVisible: true }), + createOcr({ id: 'ocr-2', isVisible: false }), + createOcr({ id: 'ocr-3', isVisible: false }), + ]; + const result = checkOcrVisibility(ocrs, assetDimensions); + + expect(result.visible).toHaveLength(2); + expect(result.hidden).toHaveLength(0); + expect(result.visible.map((o) => o.id)).toEqual(['ocr-2', 'ocr-3']); + }); + + it('should return all OCR entries as visible when all are marked not visible and no crop provided', () => { + const ocrs = [createOcr({ id: 'ocr-1', isVisible: false }), createOcr({ id: 'ocr-2', isVisible: false })]; + const result = checkOcrVisibility(ocrs, assetDimensions); + + expect(result.visible).toHaveLength(2); + expect(result.hidden).toHaveLength(0); + }); + + it('should return empty visible array when all OCR entries are already visible and no crop provided', () => { + const ocrs = [createOcr({ id: 'ocr-1', isVisible: true }), createOcr({ id: 'ocr-2', isVisible: true })]; + const result = checkOcrVisibility(ocrs, assetDimensions); + + expect(result.visible).toHaveLength(0); + expect(result.hidden).toHaveLength(0); + }); + + it('should return empty arrays when no OCR entries provided', () => { + const result = checkOcrVisibility([], assetDimensions); + + expect(result.visible).toHaveLength(0); + expect(result.hidden).toHaveLength(0); + }); + + it('should mark OCR as visible when fully inside crop area', () => { + // OCR box at normalized coords 0.1-0.2 = 100-200px in 1000x1000 image + const ocrs = [createOcr()]; + const crop = { x1: 0, y1: 0, x2: 500, y2: 500 }; + + const result = checkOcrVisibility(ocrs, assetDimensions, crop); + + expect(result.visible).toHaveLength(1); + expect(result.hidden).toHaveLength(0); + }); + + it('should mark OCR as hidden when fully outside crop area', () => { + // OCR box at normalized coords 0.8-0.9 = 800-900px + const ocrs = [createOcr({ x1: 0.8, y1: 0.8, x2: 0.9, y2: 0.8, x3: 0.9, y3: 0.9, x4: 0.8, y4: 0.9 })]; + const crop = { x1: 0, y1: 0, x2: 500, y2: 500 }; + + const result = checkOcrVisibility(ocrs, assetDimensions, crop); + + expect(result.visible).toHaveLength(0); + expect(result.hidden).toHaveLength(1); + }); + + it('should mark OCR as visible when at least 50% overlaps with crop', () => { + // OCR at 100-200px (0.1-0.2 normalized), crop starts at 150 + const ocrs = [createOcr()]; + const crop = { x1: 150, y1: 100, x2: 500, y2: 500 }; + + const result = checkOcrVisibility(ocrs, assetDimensions, crop); + + expect(result.visible).toHaveLength(1); + expect(result.hidden).toHaveLength(0); + }); + + it('should mark OCR as hidden when less than 50% overlaps with crop', () => { + // OCR at 100-200px, crop starts at 160 = 40% overlap + const ocrs = [createOcr()]; + const crop = { x1: 160, y1: 100, x2: 500, y2: 500 }; + + const result = checkOcrVisibility(ocrs, assetDimensions, crop); + + expect(result.visible).toHaveLength(0); + expect(result.hidden).toHaveLength(1); + }); + + it('should correctly categorize multiple OCR entries', () => { + const ocrs = [ + createOcr({ id: 'ocr-inside', x1: 0.1, y1: 0.1, x2: 0.2, y2: 0.1, x3: 0.2, y3: 0.2, x4: 0.1, y4: 0.2 }), + createOcr({ id: 'ocr-outside', x1: 0.8, y1: 0.8, x2: 0.9, y2: 0.8, x3: 0.9, y3: 0.9, x4: 0.8, y4: 0.9 }), + ]; + const crop = { x1: 0, y1: 0, x2: 500, y2: 500 }; + + const result = checkOcrVisibility(ocrs, assetDimensions, crop); + + expect(result.visible).toHaveLength(1); + expect(result.hidden).toHaveLength(1); + expect(result.visible[0].id).toBe('ocr-inside'); + expect(result.hidden[0].id).toBe('ocr-outside'); + }); + + it('should handle rotated/skewed OCR polygons by using bounding box', () => { + // Rotated rectangle - the function should compute the bounding box correctly + const ocrs = [ + createOcr({ + id: 'ocr-rotated', + x1: 0.15, + y1: 0.1, // top + x2: 0.2, + y2: 0.15, // right + x3: 0.15, + y3: 0.2, // bottom + x4: 0.1, + y4: 0.15, // left + }), + ]; + const crop = { x1: 0, y1: 0, x2: 500, y2: 500 }; + + const result = checkOcrVisibility(ocrs, assetDimensions, crop); + + expect(result.visible).toHaveLength(1); + expect(result.hidden).toHaveLength(0); + }); + + it('should handle different asset dimensions', () => { + const smallDimensions = { width: 500, height: 500 }; + // OCR at 0.1-0.2 normalized = 50-100px in 500x500 image + const ocrs = [createOcr()]; + const crop = { x1: 0, y1: 0, x2: 200, y2: 200 }; + + const result = checkOcrVisibility(ocrs, smallDimensions, crop); + + expect(result.visible).toHaveLength(1); + expect(result.hidden).toHaveLength(0); + }); + + it('should categorize based on crop overlap when crop is provided, regardless of isVisible property', () => { + const ocrs = [ + createOcr({ id: 'ocr-inside-visible', isVisible: true }), // Inside crop, already visible + createOcr({ id: 'ocr-inside-not-visible', isVisible: false }), // Inside crop, not visible + createOcr({ + id: 'ocr-outside-visible', + x1: 0.8, + y1: 0.8, + x2: 0.9, + y2: 0.8, + x3: 0.9, + y3: 0.9, + x4: 0.8, + y4: 0.9, + isVisible: true, + }), // Outside crop, already visible + createOcr({ + id: 'ocr-outside-not-visible', + x1: 0.8, + y1: 0.8, + x2: 0.9, + y2: 0.8, + x3: 0.9, + y3: 0.9, + x4: 0.8, + y4: 0.9, + isVisible: false, + }), // Outside crop, not visible + ]; + const crop = { x1: 0, y1: 0, x2: 500, y2: 500 }; + + const result = checkOcrVisibility(ocrs, assetDimensions, crop); + + // When crop is provided, only overlap matters, not isVisible property + expect(result.visible).toHaveLength(2); + expect(result.hidden).toHaveLength(2); + expect(result.visible.map((o) => o.id)).toContain('ocr-inside-visible'); + expect(result.visible.map((o) => o.id)).toContain('ocr-inside-not-visible'); + expect(result.hidden.map((o) => o.id)).toContain('ocr-outside-visible'); + expect(result.hidden.map((o) => o.id)).toContain('ocr-outside-not-visible'); + }); + + it('should handle mixed visibility states with partial overlap and crop', () => { + const ocrs = [ + createOcr({ id: 'ocr-partial-50', isVisible: true }), // 50% overlap + createOcr({ id: 'ocr-partial-40', isVisible: false }), // 40% overlap + ]; + const crop1 = { x1: 150, y1: 100, x2: 500, y2: 500 }; // 50% overlap + const crop2 = { x1: 160, y1: 100, x2: 500, y2: 500 }; // 40% overlap + + const result1 = checkOcrVisibility([ocrs[0]], assetDimensions, crop1); + const result2 = checkOcrVisibility([ocrs[1]], assetDimensions, crop2); + + // 50% overlap should be visible + expect(result1.visible).toHaveLength(1); + expect(result1.hidden).toHaveLength(0); + + // 40% overlap should be hidden + expect(result2.visible).toHaveLength(0); + expect(result2.hidden).toHaveLength(1); + }); +}); diff --git a/server/src/utils/editor.ts b/server/src/utils/editor.ts new file mode 100644 index 0000000000..21678f2a82 --- /dev/null +++ b/server/src/utils/editor.ts @@ -0,0 +1,107 @@ +import { AssetFace } from 'src/database'; +import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; +import { ImageDimensions } from 'src/types'; + +type BoundingBox = { + x1: number; + y1: number; + x2: number; + y2: number; +}; + +export const boundingBoxOverlap = (boxA: BoundingBox, boxB: BoundingBox) => { + const overlapX1 = Math.max(boxA.x1, boxB.x1); + const overlapY1 = Math.max(boxA.y1, boxB.y1); + const overlapX2 = Math.min(boxA.x2, boxB.x2); + const overlapY2 = Math.min(boxA.y2, boxB.y2); + + const overlapArea = Math.max(0, overlapX2 - overlapX1) * Math.max(0, overlapY2 - overlapY1); + const faceArea = (boxA.x2 - boxA.x1) * (boxA.y2 - boxA.y1); + return overlapArea / faceArea; +}; + +const scale = (box: BoundingBox, target: ImageDimensions, source?: ImageDimensions) => { + const { width: sourceWidth = 1, height: sourceHeight = 1 } = source ?? {}; + + return { + x1: (box.x1 / sourceWidth) * target.width, + y1: (box.y1 / sourceHeight) * target.height, + x2: (box.x2 / sourceWidth) * target.width, + y2: (box.y2 / sourceHeight) * target.height, + }; +}; + +export const checkFaceVisibility = ( + faces: AssetFace[], + originalAssetDimensions: ImageDimensions, + crop?: BoundingBox, +): { visible: AssetFace[]; hidden: AssetFace[] } => { + if (!crop) { + return { + visible: faces.filter((face) => !face.isVisible), + hidden: [], + }; + } + + const status = faces.map((face) => { + const scaledFace = scale( + { + x1: face.boundingBoxX1, + y1: face.boundingBoxY1, + x2: face.boundingBoxX2, + y2: face.boundingBoxY2, + }, + originalAssetDimensions, + { width: face.imageWidth, height: face.imageHeight }, + ); + + const overlapPercentage = boundingBoxOverlap(scaledFace, crop); + + return { + face, + isVisible: overlapPercentage >= 0.5, + }; + }); + + return { + visible: status.filter((s) => s.isVisible).map((s) => s.face), + hidden: status.filter((s) => !s.isVisible).map((s) => s.face), + }; +}; + +export const checkOcrVisibility = ( + ocrs: (AssetOcrResponseDto & { isVisible: boolean })[], + originalAssetDimensions: ImageDimensions, + crop?: BoundingBox, +): { visible: AssetOcrResponseDto[]; hidden: AssetOcrResponseDto[] } => { + if (!crop) { + return { + visible: ocrs.filter((ocr) => !ocr.isVisible), + hidden: [], + }; + } + + const status = ocrs.map((ocr) => { + const ocrBox = scale( + { + x1: Math.min(ocr.x1, ocr.x2, ocr.x3, ocr.x4), + y1: Math.min(ocr.y1, ocr.y2, ocr.y3, ocr.y4), + x2: Math.max(ocr.x1, ocr.x2, ocr.x3, ocr.x4), + y2: Math.max(ocr.y1, ocr.y2, ocr.y3, ocr.y4), + }, + originalAssetDimensions, + ); + + const overlapPercentage = boundingBoxOverlap(ocrBox, crop); + + return { + ocr, + isVisible: overlapPercentage >= 0.5, + }; + }); + + return { + visible: status.filter((s) => s.isVisible).map((s) => s.ocr), + hidden: status.filter((s) => !s.isVisible).map((s) => s.ocr), + }; +}; diff --git a/server/src/utils/transform.spec.ts b/server/src/utils/transform.spec.ts new file mode 100644 index 0000000000..5efeac02a6 --- /dev/null +++ b/server/src/utils/transform.spec.ts @@ -0,0 +1,293 @@ +import { AssetEditAction, AssetEditActionItem, MirrorAxis } from 'src/dtos/editing.dto'; +import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; +import { transformFaceBoundingBox, transformOcrBoundingBox } from 'src/utils/transform'; +import { describe, expect, it } from 'vitest'; + +describe('transformFaceBoundingBox', () => { + const baseFace = { + boundingBoxX1: 100, + boundingBoxY1: 100, + boundingBoxX2: 200, + boundingBoxY2: 200, + imageWidth: 1000, + imageHeight: 800, + }; + + const baseDimensions = { width: 1000, height: 800 }; + + describe('with no edits', () => { + it('should return unchanged bounding box', () => { + const result = transformFaceBoundingBox(baseFace, [], baseDimensions); + expect(result).toEqual(baseFace); + }); + }); + + describe('with crop edit', () => { + it('should adjust bounding box for crop offset', () => { + const edits: AssetEditActionItem[] = [ + { action: AssetEditAction.Crop, parameters: { x: 50, y: 50, width: 400, height: 300 } }, + ]; + const result = transformFaceBoundingBox(baseFace, edits, baseDimensions); + + expect(result.boundingBoxX1).toBe(50); + expect(result.boundingBoxY1).toBe(50); + expect(result.boundingBoxX2).toBe(150); + expect(result.boundingBoxY2).toBe(150); + expect(result.imageWidth).toBe(400); + expect(result.imageHeight).toBe(300); + }); + + it('should handle face partially outside crop area', () => { + const edits: AssetEditActionItem[] = [ + { action: AssetEditAction.Crop, parameters: { x: 150, y: 150, width: 400, height: 300 } }, + ]; + const result = transformFaceBoundingBox(baseFace, edits, baseDimensions); + + expect(result.boundingBoxX1).toBe(-50); + expect(result.boundingBoxY1).toBe(-50); + expect(result.boundingBoxX2).toBe(50); + expect(result.boundingBoxY2).toBe(50); + }); + }); + + describe('with rotate edit', () => { + it('should rotate 90 degrees clockwise', () => { + const edits: AssetEditActionItem[] = [{ action: AssetEditAction.Rotate, parameters: { angle: 90 } }]; + const result = transformFaceBoundingBox(baseFace, edits, baseDimensions); + + expect(result.imageWidth).toBe(800); + expect(result.imageHeight).toBe(1000); + + expect(result.boundingBoxX1).toBe(600); + expect(result.boundingBoxY1).toBe(100); + expect(result.boundingBoxX2).toBe(700); + expect(result.boundingBoxY2).toBe(200); + }); + + it('should rotate 180 degrees', () => { + const edits: AssetEditActionItem[] = [{ action: AssetEditAction.Rotate, parameters: { angle: 180 } }]; + const result = transformFaceBoundingBox(baseFace, edits, baseDimensions); + + expect(result.imageWidth).toBe(1000); + expect(result.imageHeight).toBe(800); + + expect(result.boundingBoxX1).toBe(800); + expect(result.boundingBoxY1).toBe(600); + expect(result.boundingBoxX2).toBe(900); + expect(result.boundingBoxY2).toBe(700); + }); + + it('should rotate 270 degrees', () => { + const edits: AssetEditActionItem[] = [{ action: AssetEditAction.Rotate, parameters: { angle: 270 } }]; + const result = transformFaceBoundingBox(baseFace, edits, baseDimensions); + + expect(result.imageWidth).toBe(800); + expect(result.imageHeight).toBe(1000); + }); + }); + + describe('with mirror edit', () => { + it('should mirror horizontally', () => { + const edits: AssetEditActionItem[] = [ + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }, + ]; + const result = transformFaceBoundingBox(baseFace, edits, baseDimensions); + + expect(result.boundingBoxX1).toBe(800); + expect(result.boundingBoxY1).toBe(100); + expect(result.boundingBoxX2).toBe(900); + expect(result.boundingBoxY2).toBe(200); + expect(result.imageWidth).toBe(1000); + expect(result.imageHeight).toBe(800); + }); + + it('should mirror vertically', () => { + const edits: AssetEditActionItem[] = [ + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } }, + ]; + const result = transformFaceBoundingBox(baseFace, edits, baseDimensions); + + expect(result.boundingBoxX1).toBe(100); + expect(result.boundingBoxY1).toBe(600); + expect(result.boundingBoxX2).toBe(200); + expect(result.boundingBoxY2).toBe(700); + expect(result.imageWidth).toBe(1000); + expect(result.imageHeight).toBe(800); + }); + }); + + describe('with combined edits', () => { + it('should apply crop then rotate', () => { + const edits: AssetEditActionItem[] = [ + { action: AssetEditAction.Crop, parameters: { x: 50, y: 50, width: 400, height: 300 } }, + { action: AssetEditAction.Rotate, parameters: { angle: 90 } }, + ]; + const result = transformFaceBoundingBox(baseFace, edits, baseDimensions); + + expect(result.imageWidth).toBe(300); + expect(result.imageHeight).toBe(400); + }); + + it('should apply crop then mirror', () => { + const edits: AssetEditActionItem[] = [ + { action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 500, height: 400 } }, + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } }, + ]; + const result = transformFaceBoundingBox(baseFace, edits, baseDimensions); + + expect(result.boundingBoxX1).toBe(100); + expect(result.boundingBoxX2).toBe(200); + expect(result.boundingBoxY1).toBe(200); + expect(result.boundingBoxY2).toBe(300); + }); + }); + + describe('with scaled dimensions', () => { + it('should scale face to match different image dimensions', () => { + const scaledDimensions = { width: 500, height: 400 }; // Half the original size + const edits: AssetEditActionItem[] = [ + { action: AssetEditAction.Crop, parameters: { x: 50, y: 50, width: 200, height: 150 } }, + ]; + const result = transformFaceBoundingBox(baseFace, edits, scaledDimensions); + + expect(result.boundingBoxX1).toBe(0); + expect(result.boundingBoxY1).toBe(0); + expect(result.boundingBoxX2).toBe(50); + expect(result.boundingBoxY2).toBe(50); + }); + }); +}); + +describe('transformOcrBoundingBox', () => { + const baseOcr: AssetOcrResponseDto = { + id: 'ocr-1', + assetId: 'asset-1', + x1: 0.1, + y1: 0.1, + x2: 0.2, + y2: 0.1, + x3: 0.2, + y3: 0.2, + x4: 0.1, + y4: 0.2, + boxScore: 0.9, + textScore: 0.85, + text: 'Test OCR', + }; + + const baseDimensions = { width: 1000, height: 800 }; + + describe('with no edits', () => { + it('should return unchanged bounding box', () => { + const result = transformOcrBoundingBox(baseOcr, [], baseDimensions); + expect(result).toEqual(baseOcr); + }); + }); + + describe('with crop edit', () => { + it('should adjust normalized coordinates for crop', () => { + const edits: AssetEditActionItem[] = [ + { action: AssetEditAction.Crop, parameters: { x: 100, y: 80, width: 400, height: 320 } }, + ]; + const result = transformOcrBoundingBox(baseOcr, edits, baseDimensions); + + // Original OCR: (0.1,0.1)-(0.2,0.2) on 1000x800 = (100,80)-(200,160) + // After crop offset (100,80): (0,0)-(100,80) + // Normalized to 400x320: (0,0)-(0.25,0.25) + expect(result.x1).toBeCloseTo(0, 5); + expect(result.y1).toBeCloseTo(0, 5); + expect(result.x2).toBeCloseTo(0.25, 5); + expect(result.y2).toBeCloseTo(0, 5); + expect(result.x3).toBeCloseTo(0.25, 5); + expect(result.y3).toBeCloseTo(0.25, 5); + expect(result.x4).toBeCloseTo(0, 5); + expect(result.y4).toBeCloseTo(0.25, 5); + }); + }); + + describe('with rotate edit', () => { + it('should rotate normalized coordinates 90 degrees and reorder points', () => { + const edits: AssetEditActionItem[] = [{ action: AssetEditAction.Rotate, parameters: { angle: 90 } }]; + const result = transformOcrBoundingBox(baseOcr, edits, baseDimensions); + + expect(result.id).toBe(baseOcr.id); + expect(result.text).toBe(baseOcr.text); + expect(result.x1).toBeCloseTo(0.8, 5); + expect(result.y1).toBeCloseTo(0.1, 5); + expect(result.x2).toBeCloseTo(0.9, 5); + expect(result.y2).toBeCloseTo(0.1, 5); + expect(result.x3).toBeCloseTo(0.9, 5); + expect(result.y3).toBeCloseTo(0.2, 5); + expect(result.x4).toBeCloseTo(0.8, 5); + expect(result.y4).toBeCloseTo(0.2, 5); + }); + + it('should rotate 180 degrees and reorder points', () => { + const edits: AssetEditActionItem[] = [{ action: AssetEditAction.Rotate, parameters: { angle: 180 } }]; + const result = transformOcrBoundingBox(baseOcr, edits, baseDimensions); + + expect(result.x1).toBeCloseTo(0.8, 5); + expect(result.y1).toBeCloseTo(0.8, 5); + expect(result.x2).toBeCloseTo(0.9, 5); + expect(result.y2).toBeCloseTo(0.8, 5); + expect(result.x3).toBeCloseTo(0.9, 5); + expect(result.y3).toBeCloseTo(0.9, 5); + expect(result.x4).toBeCloseTo(0.8, 5); + expect(result.y4).toBeCloseTo(0.9, 5); + }); + + it('should rotate 270 degrees and reorder points', () => { + const edits: AssetEditActionItem[] = [{ action: AssetEditAction.Rotate, parameters: { angle: 270 } }]; + const result = transformOcrBoundingBox(baseOcr, edits, baseDimensions); + + expect(result.id).toBe(baseOcr.id); + expect(result.text).toBe(baseOcr.text); + expect(result.x1).toBeCloseTo(0.1, 5); + expect(result.y1).toBeCloseTo(0.8, 5); + expect(result.x2).toBeCloseTo(0.2, 5); + expect(result.y2).toBeCloseTo(0.8, 5); + expect(result.x3).toBeCloseTo(0.2, 5); + expect(result.y3).toBeCloseTo(0.9, 5); + expect(result.x4).toBeCloseTo(0.1, 5); + expect(result.y4).toBeCloseTo(0.9, 5); + }); + }); + + describe('with mirror edit', () => { + it('should mirror horizontally', () => { + const edits: AssetEditActionItem[] = [ + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }, + ]; + const result = transformOcrBoundingBox(baseOcr, edits, baseDimensions); + + expect(result.x1).toBeCloseTo(0.9, 5); + expect(result.y1).toBeCloseTo(0.1, 5); + }); + + it('should mirror vertically', () => { + const edits: AssetEditActionItem[] = [ + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } }, + ]; + const result = transformOcrBoundingBox(baseOcr, edits, baseDimensions); + + expect(result.x1).toBeCloseTo(0.1, 5); + expect(result.y1).toBeCloseTo(0.9, 5); + }); + }); + + describe('with combined edits', () => { + it('should preserve OCR metadata through transforms', () => { + const edits: AssetEditActionItem[] = [ + { action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 500, height: 400 } }, + { action: AssetEditAction.Rotate, parameters: { angle: 90 } }, + ]; + const result = transformOcrBoundingBox(baseOcr, edits, baseDimensions); + + expect(result.id).toBe(baseOcr.id); + expect(result.assetId).toBe(baseOcr.assetId); + expect(result.boxScore).toBe(baseOcr.boxScore); + expect(result.textScore).toBe(baseOcr.textScore); + expect(result.text).toBe(baseOcr.text); + }); + }); +}); diff --git a/server/src/utils/transform.ts b/server/src/utils/transform.ts new file mode 100644 index 0000000000..b57a198cc6 --- /dev/null +++ b/server/src/utils/transform.ts @@ -0,0 +1,227 @@ +import { AssetEditAction, AssetEditActionItem } from 'src/dtos/editing.dto'; +import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; +import { ImageDimensions } from 'src/types'; +import { applyToPoint, compose, flipX, flipY, identity, Matrix, rotate, scale, translate } from 'transformation-matrix'; + +export const getOutputDimensions = ( + edits: AssetEditActionItem[], + startingDimensions: ImageDimensions, +): ImageDimensions => { + let { width, height } = startingDimensions; + + const crop = edits.find((edit) => edit.action === AssetEditAction.Crop); + if (crop) { + width = crop.parameters.width; + height = crop.parameters.height; + } + + for (const edit of edits) { + if (edit.action === AssetEditAction.Rotate) { + const angleDegrees = edit.parameters.angle; + if (angleDegrees === 90 || angleDegrees === 270) { + [width, height] = [height, width]; + } + } + } + + return { width, height }; +}; + +export const createAffineMatrix = ( + edits: AssetEditActionItem[], + scalingParameters?: { + pointSpace: ImageDimensions; + targetSpace: ImageDimensions; + }, +): Matrix => { + let scalingMatrix: Matrix = identity(); + + if (scalingParameters) { + const { pointSpace, targetSpace } = scalingParameters; + const scaleX = targetSpace.width / pointSpace.width; + scalingMatrix = scale(scaleX); + } + + return compose( + scalingMatrix, + ...edits.map((edit) => { + switch (edit.action) { + case 'rotate': { + const angleInRadians = (-edit.parameters.angle * Math.PI) / 180; + return rotate(angleInRadians); + } + case 'mirror': { + return edit.parameters.axis === 'horizontal' ? flipY() : flipX(); + } + default: { + return identity(); + } + } + }), + ); +}; + +type Point = { x: number; y: number }; + +type TransformState = { + points: Point[]; + currentWidth: number; + currentHeight: number; +}; + +/** + * Transforms an array of points through a series of edit operations (crop, rotate, mirror). + * Points should be in absolute pixel coordinates relative to the starting dimensions. + */ +const transformPoints = ( + points: Point[], + edits: AssetEditActionItem[], + startingDimensions: ImageDimensions, +): TransformState => { + let currentWidth = startingDimensions.width; + let currentHeight = startingDimensions.height; + let transformedPoints = [...points]; + + // Handle crop first + const crop = edits.find((edit) => edit.action === 'crop'); + if (crop) { + const { x: cropX, y: cropY, width: cropWidth, height: cropHeight } = crop.parameters; + transformedPoints = transformedPoints.map((p) => ({ + x: p.x - cropX, + y: p.y - cropY, + })); + currentWidth = cropWidth; + currentHeight = cropHeight; + } + + // Apply rotate and mirror transforms + for (const edit of edits) { + let matrix: Matrix = identity(); + if (edit.action === 'rotate') { + const angleDegrees = edit.parameters.angle; + const angleRadians = (angleDegrees * Math.PI) / 180; + const newWidth = angleDegrees === 90 || angleDegrees === 270 ? currentHeight : currentWidth; + const newHeight = angleDegrees === 90 || angleDegrees === 270 ? currentWidth : currentHeight; + + matrix = compose( + translate(newWidth / 2, newHeight / 2), + rotate(angleRadians), + translate(-currentWidth / 2, -currentHeight / 2), + ); + + currentWidth = newWidth; + currentHeight = newHeight; + } else if (edit.action === 'mirror') { + matrix = compose( + translate(currentWidth / 2, currentHeight / 2), + edit.parameters.axis === 'horizontal' ? flipY() : flipX(), + translate(-currentWidth / 2, -currentHeight / 2), + ); + } else { + // Skip non-affine transformations + continue; + } + + transformedPoints = transformedPoints.map((p) => applyToPoint(matrix, p)); + } + + return { + points: transformedPoints, + currentWidth, + currentHeight, + }; +}; + +type FaceBoundingBox = { + boundingBoxX1: number; + boundingBoxX2: number; + boundingBoxY1: number; + boundingBoxY2: number; + imageWidth: number; + imageHeight: number; +}; + +export const transformFaceBoundingBox = ( + box: FaceBoundingBox, + edits: AssetEditActionItem[], + imageDimensions: ImageDimensions, +): FaceBoundingBox => { + if (edits.length === 0) { + return box; + } + + const scaleX = imageDimensions.width / box.imageWidth; + const scaleY = imageDimensions.height / box.imageHeight; + + const points: Point[] = [ + { x: box.boundingBoxX1 * scaleX, y: box.boundingBoxY1 * scaleY }, + { x: box.boundingBoxX2 * scaleX, y: box.boundingBoxY2 * scaleY }, + ]; + + const { points: transformedPoints, currentWidth, currentHeight } = transformPoints(points, edits, imageDimensions); + + // Ensure x1,y1 is top-left and x2,y2 is bottom-right + const [p1, p2] = transformedPoints; + return { + boundingBoxX1: Math.min(p1.x, p2.x), + boundingBoxY1: Math.min(p1.y, p2.y), + boundingBoxX2: Math.max(p1.x, p2.x), + boundingBoxY2: Math.max(p1.y, p2.y), + imageWidth: currentWidth, + imageHeight: currentHeight, + }; +}; + +const reorderQuadPointsForRotation = (points: Point[], rotationDegrees: number): Point[] => { + const [p1, p2, p3, p4] = points; + switch (rotationDegrees) { + case 90: { + return [p4, p1, p2, p3]; + } + case 180: { + return [p3, p4, p1, p2]; + } + case 270: { + return [p2, p3, p4, p1]; + } + default: { + return points; + } + } +}; + +export const transformOcrBoundingBox = ( + box: AssetOcrResponseDto, + edits: AssetEditActionItem[], + imageDimensions: ImageDimensions, +): AssetOcrResponseDto => { + if (edits.length === 0) { + return box; + } + + const points: Point[] = [ + { x: box.x1 * imageDimensions.width, y: box.y1 * imageDimensions.height }, + { x: box.x2 * imageDimensions.width, y: box.y2 * imageDimensions.height }, + { x: box.x3 * imageDimensions.width, y: box.y3 * imageDimensions.height }, + { x: box.x4 * imageDimensions.width, y: box.y4 * imageDimensions.height }, + ]; + + const { points: transformedPoints, currentWidth, currentHeight } = transformPoints(points, edits, imageDimensions); + + // Reorder points to maintain semantic ordering (topLeft, topRight, bottomRight, bottomLeft) + const netRotation = edits.find((e) => e.action == AssetEditAction.Rotate)?.parameters.angle ?? 0 % 360; + const reorderedPoints = reorderQuadPointsForRotation(transformedPoints, netRotation); + + const [p1, p2, p3, p4] = reorderedPoints; + return { + ...box, + x1: p1.x / currentWidth, + y1: p1.y / currentHeight, + x2: p2.x / currentWidth, + y2: p2.y / currentHeight, + x3: p3.x / currentWidth, + y3: p3.y / currentHeight, + x4: p4.x / currentWidth, + y4: p4.y / currentHeight, + }; +}; diff --git a/server/src/validation.ts b/server/src/validation.ts index a565474e03..0a53e09ca5 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -81,6 +81,49 @@ export const ValidateUUID = (options?: UUIDOptions & ApiPropertyOptions) => { ); }; +export function IsAxisAlignedRotation() { + return ValidateBy( + { + name: 'isAxisAlignedRotation', + validator: { + validate(value: any) { + return [0, 90, 180, 270].includes(value); + }, + defaultMessage: buildMessage( + (eachPrefix) => eachPrefix + '$property must be one of the following values: 0, 90, 180, 270', + {}, + ), + }, + }, + {}, + ); +} + +@ValidatorConstraint({ name: 'uniqueEditActions' }) +class UniqueEditActionsValidator implements ValidatorConstraintInterface { + validate(edits: { action: string; parameters?: unknown }[]): boolean { + if (!Array.isArray(edits)) { + return true; + } + + const actionSet = new Set(); + for (const edit of edits) { + const key = edit.action === 'mirror' ? `${edit.action}-${JSON.stringify(edit.parameters)}` : edit.action; + if (actionSet.has(key)) { + return false; + } + actionSet.add(key); + } + return true; + } + + defaultMessage(): string { + return 'Duplicate edit actions are not allowed'; + } +} + +export const IsUniqueEditActions = () => Validate(UniqueEditActionsValidator); + export class UUIDParamDto { @IsNotEmpty() @IsUUID('4') diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 6e4193c110..3478e31fe9 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -1,43 +1,61 @@ import { AssetFace, AssetFile, Exif } from 'src/database'; import { MapAsset } from 'src/dtos/asset-response.dto'; +import { AssetEditAction, AssetEditActionItem } from 'src/dtos/editing.dto'; import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; import { StorageAsset } from 'src/types'; import { authStub } from 'test/fixtures/auth.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { userStub } from 'test/fixtures/user.stub'; +import { factory } from 'test/small.factory'; -export const previewFile: AssetFile = { - id: 'file-1', - type: AssetFileType.Preview, - path: '/uploads/user-id/thumbs/path.jpg', -}; +export const previewFile = factory.assetFile({ type: AssetFileType.Preview }); -const thumbnailFile: AssetFile = { - id: 'file-2', +const thumbnailFile = factory.assetFile({ type: AssetFileType.Thumbnail, path: '/uploads/user-id/webp/path.ext', -}; +}); -const fullsizeFile: AssetFile = { - id: 'file-3', +const fullsizeFile = factory.assetFile({ type: AssetFileType.FullSize, path: '/uploads/user-id/fullsize/path.webp', -}; +}); -const sidecarFileWithExt: AssetFile = { - id: 'sidecar-with-ext', +const sidecarFileWithExt = factory.assetFile({ type: AssetFileType.Sidecar, path: '/original/path.ext.xmp', -}; +}); -const sidecarFileWithoutExt: AssetFile = { - id: 'sidecar-without-ext', +const sidecarFileWithoutExt = factory.assetFile({ type: AssetFileType.Sidecar, path: '/original/path.xmp', -}; +}); + +const editedPreviewFile = factory.assetFile({ + type: AssetFileType.PreviewEdited, + path: '/uploads/user-id/preview/path_edited.jpg', +}); + +const editedThumbnailFile = factory.assetFile({ + type: AssetFileType.ThumbnailEdited, + path: '/uploads/user-id/thumbnail/path_edited.jpg', +}); + +const editedFullsizeFile = factory.assetFile({ + type: AssetFileType.FullSizeEdited, + path: '/uploads/user-id/fullsize/path_edited.jpg', +}); const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile]; +const editedFiles: AssetFile[] = [ + fullsizeFile, + previewFile, + thumbnailFile, + editedFullsizeFile, + editedPreviewFile, + editedThumbnailFile, +]; + export const stackStub = (stackId: string, assets: (MapAsset & { exifInfo: Exif })[]) => { return { id: stackId, @@ -104,6 +122,9 @@ export const assetStub = { stackId: null, updateId: '42', visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), noWebpPath: Object.freeze({ @@ -142,6 +163,9 @@ export const assetStub = { stackId: null, updateId: '42', visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), noThumbhash: Object.freeze({ @@ -177,6 +201,9 @@ export const assetStub = { stackId: null, updateId: '42', visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), primaryImage: Object.freeze({ @@ -222,6 +249,9 @@ export const assetStub = { updateId: '42', libraryId: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), image: Object.freeze({ @@ -264,9 +294,10 @@ export const assetStub = { stack: null, orientation: '', projectionType: null, - height: 3840, - width: 2160, + height: null, + width: null, visibility: AssetVisibility.Timeline, + edits: [], }), trashed: Object.freeze({ @@ -307,6 +338,9 @@ export const assetStub = { stackId: null, updateId: '42', visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), trashedOffline: Object.freeze({ @@ -347,6 +381,9 @@ export const assetStub = { stackId: null, updateId: '42', visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), archived: Object.freeze({ id: 'asset-id', @@ -386,6 +423,9 @@ export const assetStub = { stackId: null, updateId: '42', visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), external: Object.freeze({ @@ -425,6 +465,9 @@ export const assetStub = { stackId: null, stack: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), image1: Object.freeze({ @@ -464,6 +507,9 @@ export const assetStub = { libraryId: null, stack: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), imageFrom2015: Object.freeze({ @@ -502,6 +548,9 @@ export const assetStub = { duplicateId: null, isOffline: false, visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), video: Object.freeze({ @@ -542,6 +591,9 @@ export const assetStub = { libraryId: null, stackId: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), livePhotoMotionAsset: Object.freeze({ @@ -559,7 +611,10 @@ export const assetStub = { files: [] as AssetFile[], libraryId: null, visibility: AssetVisibility.Hidden, - } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif }), + width: null, + height: null, + edits: [] as AssetEditActionItem[], + } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif; edits: AssetEditActionItem[] }), livePhotoStillAsset: Object.freeze({ id: 'live-photo-still-asset', @@ -577,7 +632,10 @@ export const assetStub = { files, faces: [] as AssetFace[], visibility: AssetVisibility.Timeline, - } as MapAsset & { faces: AssetFace[]; files: AssetFile[] }), + width: null, + height: null, + edits: [] as AssetEditActionItem[], + } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }), livePhotoWithOriginalFileName: Object.freeze({ id: 'live-photo-still-asset', @@ -597,7 +655,10 @@ export const assetStub = { libraryId: null, faces: [] as AssetFace[], visibility: AssetVisibility.Timeline, - } as MapAsset & { faces: AssetFace[]; files: AssetFile[] }), + width: null, + height: null, + edits: [] as AssetEditActionItem[], + } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }), withLocation: Object.freeze({ id: 'asset-with-favorite-id', @@ -641,6 +702,9 @@ export const assetStub = { isOffline: false, tags: [], visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), sidecar: Object.freeze({ @@ -676,6 +740,9 @@ export const assetStub = { libraryId: null, stackId: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), sidecarWithoutExt: Object.freeze({ @@ -708,6 +775,9 @@ export const assetStub = { duplicateId: null, isOffline: false, visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), hasEncodedVideo: Object.freeze({ @@ -747,6 +817,9 @@ export const assetStub = { stackId: null, stack: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), hasFileExtension: Object.freeze({ @@ -783,6 +856,9 @@ export const assetStub = { duplicateId: null, isOffline: false, visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), imageDng: Object.freeze({ @@ -823,6 +899,9 @@ export const assetStub = { libraryId: null, stackId: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), imageHif: Object.freeze({ @@ -863,6 +942,9 @@ export const assetStub = { libraryId: null, stackId: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), panoramaTif: Object.freeze({ id: 'asset-id', @@ -902,5 +984,110 @@ export const assetStub = { libraryId: null, stackId: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], + }), + withCropEdit: Object.freeze({ + id: 'asset-id', + status: AssetStatus.Active, + deviceAssetId: 'device-asset-id', + fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), + fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), + owner: userStub.user1, + ownerId: 'user-id', + deviceId: 'device-id', + originalPath: '/original/path.jpg', + files, + checksum: Buffer.from('file hash', 'utf8'), + type: AssetType.Image, + thumbhash: Buffer.from('blablabla', 'base64'), + encodedVideoPath: null, + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), + localDateTime: new Date('2025-01-01T01:02:03.456Z'), + isFavorite: true, + duration: null, + isExternal: false, + livePhotoVideo: null, + livePhotoVideoId: null, + updateId: 'foo', + libraryId: null, + stackId: null, + sharedLinks: [], + originalFileName: 'asset-id.jpg', + faces: [], + deletedAt: null, + sidecarPath: null, + exifInfo: { + fileSizeInByte: 5000, + exifImageHeight: 3840, + exifImageWidth: 2160, + } as Exif, + duplicateId: null, + isOffline: false, + stack: null, + orientation: '', + projectionType: null, + height: 3840, + width: 2160, + visibility: AssetVisibility.Timeline, + edits: [ + { + action: AssetEditAction.Crop, + parameters: { + width: 1512, + height: 1152, + x: 216, + y: 1512, + }, + }, + ] as AssetEditActionItem[], + }), + withoutEdits: Object.freeze({ + id: 'asset-id', + status: AssetStatus.Active, + deviceAssetId: 'device-asset-id', + fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), + fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), + owner: userStub.user1, + ownerId: 'user-id', + deviceId: 'device-id', + originalPath: '/original/path.jpg', + files: editedFiles, + checksum: Buffer.from('file hash', 'utf8'), + type: AssetType.Image, + thumbhash: Buffer.from('blablabla', 'base64'), + encodedVideoPath: null, + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), + localDateTime: new Date('2025-01-01T01:02:03.456Z'), + isFavorite: true, + duration: null, + isExternal: false, + livePhotoVideo: null, + livePhotoVideoId: null, + updateId: 'foo', + libraryId: null, + stackId: null, + sharedLinks: [], + originalFileName: 'asset-id.jpg', + faces: [], + deletedAt: null, + sidecarPath: null, + exifInfo: { + fileSizeInByte: 5000, + exifImageHeight: 3840, + exifImageWidth: 2160, + } as Exif, + duplicateId: null, + isOffline: false, + stack: null, + orientation: '', + projectionType: null, + height: 3840, + width: 2160, + visibility: AssetVisibility.Timeline, + edits: [], }), }; diff --git a/server/test/fixtures/face.stub.ts b/server/test/fixtures/face.stub.ts index f655a3944e..94a2dcff22 100644 --- a/server/test/fixtures/face.stub.ts +++ b/server/test/fixtures/face.stub.ts @@ -25,6 +25,7 @@ export const faceStub = { deletedAt: new Date(), updatedAt: new Date('2023-01-01T00:00:00Z'), updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', + isVisible: true, }), primaryFace1: Object.freeze({ id: 'assetFaceId2', @@ -43,6 +44,7 @@ export const faceStub = { deletedAt: null, updatedAt: new Date('2023-01-01T00:00:00Z'), updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', + isVisible: true, }), mergeFace1: Object.freeze({ id: 'assetFaceId3', @@ -61,6 +63,7 @@ export const faceStub = { deletedAt: null, updatedAt: new Date('2023-01-01T00:00:00Z'), updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', + isVisible: true, }), noPerson1: Object.freeze({ id: 'assetFaceId8', @@ -79,6 +82,7 @@ export const faceStub = { deletedAt: null, updatedAt: new Date('2023-01-01T00:00:00Z'), updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', + isVisible: true, }), noPerson2: Object.freeze({ id: 'assetFaceId9', @@ -97,6 +101,7 @@ export const faceStub = { deletedAt: null, updatedAt: new Date('2023-01-01T00:00:00Z'), updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', + isVisible: true, }), fromExif1: Object.freeze({ id: 'assetFaceId9', @@ -114,6 +119,7 @@ export const faceStub = { deletedAt: null, updatedAt: new Date('2023-01-01T00:00:00Z'), updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', + isVisible: true, }), fromExif2: Object.freeze({ id: 'assetFaceId9', @@ -131,6 +137,7 @@ export const faceStub = { deletedAt: null, updatedAt: new Date('2023-01-01T00:00:00Z'), updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', + isVisible: true, }), withBirthDate: Object.freeze({ id: 'assetFaceId10', @@ -148,5 +155,6 @@ export const faceStub = { deletedAt: null, updatedAt: new Date('2023-01-01T00:00:00Z'), updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', + isVisible: true, }), }; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 802b46a986..6aa76dd4dc 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -142,6 +142,11 @@ export const sharedLinkStub = { rating: 3, updatedAt: today, updateId: '42', + libraryId: null, + stackId: null, + visibility: AssetVisibility.Timeline, + width: 500, + height: 500, }, sharedLinks: [], faces: [], @@ -152,6 +157,8 @@ export const sharedLinkStub = { libraryId: null, stackId: null, visibility: AssetVisibility.Timeline, + width: 500, + height: 500, }, ], albumId: null, diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 82ea2cd1fc..17b0e232b6 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -56,6 +56,7 @@ import { AlbumTable } from 'src/schema/tables/album.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; +import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table'; import { AssetTable } from 'src/schema/tables/asset.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table'; import { MemoryTable } from 'src/schema/tables/memory.table'; @@ -68,6 +69,7 @@ import { UserTable } from 'src/schema/tables/user.table'; import { BASE_SERVICE_DEPENDENCIES, BaseService } from 'src/services/base.service'; import { MetadataService } from 'src/services/metadata.service'; import { SyncService } from 'src/services/sync.service'; +import { UploadFile } from 'src/types'; import { mockEnvData } from 'test/repositories/config.repository.mock'; import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock'; import { factory, newDate, newEmbedding, newUuid } from 'test/small.factory'; @@ -179,6 +181,12 @@ export class MediumTestContext { return { asset, result }; } + async newMetadata(dto: Insertable) { + const { assetId, ...item } = dto; + const result = await this.get(AssetRepository).upsertMetadata(assetId, [item]); + return { metadata: dto, result }; + } + async newAssetFile(dto: Insertable) { const result = await this.get(AssetRepository).upsertFile(dto); return { result }; @@ -573,6 +581,7 @@ const assetFaceInsert = (assetFace: Partial & { assetId: string }) => imageWidth: assetFace.imageWidth ?? 10, personId: assetFace.personId ?? null, sourceType: assetFace.sourceType ?? SourceType.MachineLearning, + isVisible: assetFace.isVisible ?? true, }; return { @@ -739,6 +748,17 @@ const loginResponse = (): LoginResponseDto => { }; }; +const uploadFile = (file: Partial = {}) => { + return { + uuid: newUuid(), + checksum: randomBytes(32), + originalPath: '/path/to/file.jpg', + originalName: 'file.jpg', + size: 123_456, + ...file, + }; +}; + export const mediumFactory = { assetInsert, assetFaceInsert, @@ -753,4 +773,5 @@ export const mediumFactory = { loginDetails, loginResponse, tagInsert, + uploadFile, }; diff --git a/server/test/medium/specs/services/asset-media.service.spec.ts b/server/test/medium/specs/services/asset-media.service.spec.ts new file mode 100644 index 0000000000..5089850b6f --- /dev/null +++ b/server/test/medium/specs/services/asset-media.service.spec.ts @@ -0,0 +1,100 @@ +import { Kysely } from 'kysely'; +import { AssetMediaStatus } from 'src/dtos/asset-media-response.dto'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { EventRepository } from 'src/repositories/event.repository'; +import { JobRepository } from 'src/repositories/job.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { StorageRepository } from 'src/repositories/storage.repository'; +import { UserRepository } from 'src/repositories/user.repository'; +import { DB } from 'src/schema'; +import { AssetMediaService } from 'src/services/asset-media.service'; +import { AssetService } from 'src/services/asset.service'; +import { mediumFactory, newMediumService } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + return newMediumService(AssetMediaService, { + database: db || defaultDatabase, + real: [AccessRepository, AssetRepository, UserRepository], + mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(AssetService.name, () => { + describe('uploadAsset', () => { + it('should work', async () => { + const { sut, ctx } = setup(); + + ctx.getMock(StorageRepository).utimes.mockResolvedValue(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + ctx.getMock(JobRepository).queue.mockResolvedValue(); + + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 }); + const auth = factory.auth({ user: { id: user.id } }); + const file = mediumFactory.uploadFile(); + + await expect( + sut.uploadAsset( + auth, + { + deviceId: 'some-id', + deviceAssetId: 'some-id', + fileModifiedAt: new Date(), + fileCreatedAt: new Date(), + assetData: Buffer.from('some data'), + }, + file, + ), + ).resolves.toEqual({ + id: expect.any(String), + status: AssetMediaStatus.CREATED, + }); + + expect(ctx.getMock(EventRepository).emit).toHaveBeenCalledWith('AssetCreate', { + asset: expect.objectContaining({ deviceAssetId: 'some-id' }), + }); + }); + + it('should work with an empty metadata list', async () => { + const { sut, ctx } = setup(); + + ctx.getMock(StorageRepository).utimes.mockResolvedValue(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + ctx.getMock(JobRepository).queue.mockResolvedValue(); + + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 }); + const auth = factory.auth({ user: { id: user.id } }); + const file = mediumFactory.uploadFile(); + + await expect( + sut.uploadAsset( + auth, + { + deviceId: 'some-id', + deviceAssetId: 'some-id', + fileModifiedAt: new Date(), + fileCreatedAt: new Date(), + assetData: Buffer.from('some data'), + metadata: [], + }, + file, + ), + ).resolves.toEqual({ + id: expect.any(String), + status: AssetMediaStatus.CREATED, + }); + }); + }); +}); diff --git a/server/test/medium/specs/services/asset.service.spec.ts b/server/test/medium/specs/services/asset.service.spec.ts index 661c4f5cdb..d0949c153c 100644 --- a/server/test/medium/specs/services/asset.service.spec.ts +++ b/server/test/medium/specs/services/asset.service.spec.ts @@ -1,5 +1,5 @@ import { Kysely } from 'kysely'; -import { AssetFileType, JobName, SharedLinkType } from 'src/enum'; +import { AssetFileType, AssetMetadataKey, JobName, SharedLinkType } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; @@ -430,4 +430,177 @@ describe(AssetService.name, () => { ); }); }); + + describe('upsertBulkMetadata', () => { + it('should work', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + const items = [{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'foo' } }]; + + await sut.upsertBulkMetadata(auth, { items }); + + const metadata = await ctx.get(AssetRepository).getMetadata(asset.id); + expect(metadata.length).toEqual(1); + expect(metadata[0]).toEqual( + expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'foo' } }), + ); + }); + + it('should work on conflict', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'old-id' } }); + + // verify existing metadata + await expect(ctx.get(AssetRepository).getMetadata(asset.id)).resolves.toEqual([ + expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'old-id' } }), + ]); + + const items = [{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'new-id' } }]; + await sut.upsertBulkMetadata(auth, { items }); + + // verify updated metadata + await expect(ctx.get(AssetRepository).getMetadata(asset.id)).resolves.toEqual([ + expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'new-id' } }), + ]); + }); + + it('should work with multiple assets', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id }); + const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id }); + + const items = [ + { assetId: asset1.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }, + { assetId: asset2.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id2' } }, + ]; + + await sut.upsertBulkMetadata(auth, { items }); + + const metadata1 = await ctx.get(AssetRepository).getMetadata(asset1.id); + expect(metadata1).toEqual([ + expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }), + ]); + + const metadata2 = await ctx.get(AssetRepository).getMetadata(asset2.id); + expect(metadata2).toEqual([ + expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id2' } }), + ]); + }); + + it('should work with multiple metadata for the same asset', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + const items = [ + { assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }, + { assetId: asset.id, key: 'some-other-key', value: { foo: 'bar' } }, + ]; + + await sut.upsertBulkMetadata(auth, { items }); + + const metadata = await ctx.get(AssetRepository).getMetadata(asset.id); + expect(metadata).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: AssetMetadataKey.MobileApp, + value: { iCloudId: 'id1' }, + }), + expect.objectContaining({ + key: 'some-other-key', + value: { foo: 'bar' }, + }), + ]), + ); + }); + }); + + describe('deleteBulkMetadata', () => { + it('should work', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'foo' } }); + + await sut.deleteBulkMetadata(auth, { items: [{ assetId: asset.id, key: AssetMetadataKey.MobileApp }] }); + + const metadata = await ctx.get(AssetRepository).getMetadata(asset.id); + expect(metadata.length).toEqual(0); + }); + + it('should work even if the item does not exist', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + await sut.deleteBulkMetadata(auth, { items: [{ assetId: asset.id, key: AssetMetadataKey.MobileApp }] }); + + const metadata = await ctx.get(AssetRepository).getMetadata(asset.id); + expect(metadata.length).toEqual(0); + }); + + it('should work with multiple assets', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newMetadata({ assetId: asset1.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }); + const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newMetadata({ assetId: asset2.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id2' } }); + + await sut.deleteBulkMetadata(auth, { + items: [ + { assetId: asset1.id, key: AssetMetadataKey.MobileApp }, + { assetId: asset2.id, key: AssetMetadataKey.MobileApp }, + ], + }); + + await expect(ctx.get(AssetRepository).getMetadata(asset1.id)).resolves.toEqual([]); + await expect(ctx.get(AssetRepository).getMetadata(asset2.id)).resolves.toEqual([]); + }); + + it('should work with multiple metadata for the same asset', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }); + await ctx.newMetadata({ assetId: asset.id, key: 'some-other-key', value: { foo: 'bar' } }); + + await sut.deleteBulkMetadata(auth, { + items: [ + { assetId: asset.id, key: AssetMetadataKey.MobileApp }, + { assetId: asset.id, key: 'some-other-key' }, + ], + }); + + await expect(ctx.get(AssetRepository).getMetadata(asset.id)).resolves.toEqual([]); + }); + + it('should not delete unspecified keys', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }); + await ctx.newMetadata({ assetId: asset.id, key: 'some-other-key', value: { foo: 'bar' } }); + + await sut.deleteBulkMetadata(auth, { + items: [{ assetId: asset.id, key: AssetMetadataKey.MobileApp }], + }); + + const metadata = await ctx.get(AssetRepository).getMetadata(asset.id); + expect(metadata).toEqual([expect.objectContaining({ key: 'some-other-key', value: { foo: 'bar' } })]); + }); + }); }); diff --git a/server/test/medium/specs/services/ocr.service.spec.ts b/server/test/medium/specs/services/ocr.service.spec.ts index 45c34dd09e..d9d3a9f9b9 100644 --- a/server/test/medium/specs/services/ocr.service.spec.ts +++ b/server/test/medium/specs/services/ocr.service.spec.ts @@ -57,6 +57,7 @@ describe(OcrService.name, () => { id: expect.any(String), text: 'Test OCR', textScore: 0.95, + isVisible: true, x1: 10, y1: 10, x2: 50, @@ -106,6 +107,7 @@ describe(OcrService.name, () => { id: expect.any(String), text: 'One', textScore: 0.9, + isVisible: true, x1: 0, y1: 1, x2: 2, @@ -121,6 +123,7 @@ describe(OcrService.name, () => { id: expect.any(String), text: 'Two', textScore: 0.89, + isVisible: true, x1: 8, y1: 9, x2: 10, @@ -136,6 +139,7 @@ describe(OcrService.name, () => { id: expect.any(String), text: 'Three', textScore: 0.88, + isVisible: true, x1: 16, y1: 17, x2: 18, @@ -151,6 +155,7 @@ describe(OcrService.name, () => { id: expect.any(String), text: 'Four', textScore: 0.87, + isVisible: true, x1: 24, y1: 25, x2: 26, @@ -166,6 +171,7 @@ describe(OcrService.name, () => { id: expect.any(String), text: 'Five', textScore: 0.86, + isVisible: true, x1: 32, y1: 33, x2: 34, diff --git a/server/test/medium/specs/sync/sync-album-asset.spec.ts b/server/test/medium/specs/sync/sync-album-asset.spec.ts index 4f053937b8..6c094c1121 100644 --- a/server/test/medium/specs/sync/sync-album-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-album-asset.spec.ts @@ -52,6 +52,8 @@ describe(SyncRequestType.AlbumAssetsV1, () => { livePhotoVideoId: null, stackId: null, libraryId: null, + width: 1920, + height: 1080, }); const { album } = await ctx.newAlbum({ ownerId: user2.id }); await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); @@ -79,6 +81,8 @@ describe(SyncRequestType.AlbumAssetsV1, () => { livePhotoVideoId: asset.livePhotoVideoId, stackId: asset.stackId, libraryId: asset.libraryId, + width: asset.width, + height: asset.height, }, type: SyncEntityType.AlbumAssetCreateV1, }, diff --git a/server/test/medium/specs/sync/sync-asset.spec.ts b/server/test/medium/specs/sync/sync-asset.spec.ts index 066cb2de4d..acba274b4f 100644 --- a/server/test/medium/specs/sync/sync-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-asset.spec.ts @@ -37,6 +37,8 @@ describe(SyncEntityType.AssetV1, () => { deletedAt: null, duration: '0:10:00.00000', libraryId: null, + width: 1920, + height: 1080, }); const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]); @@ -60,6 +62,8 @@ describe(SyncEntityType.AssetV1, () => { stackId: null, livePhotoVideoId: null, libraryId: asset.libraryId, + width: asset.width, + height: asset.height, }, type: 'AssetV1', }, diff --git a/server/test/medium/specs/sync/sync-partner-asset.spec.ts b/server/test/medium/specs/sync/sync-partner-asset.spec.ts index c30cfcf6bd..421423a741 100644 --- a/server/test/medium/specs/sync/sync-partner-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-partner-asset.spec.ts @@ -66,6 +66,8 @@ describe(SyncRequestType.PartnerAssetsV1, () => { stackId: null, livePhotoVideoId: null, libraryId: asset.libraryId, + width: null, + height: null, }, type: SyncEntityType.PartnerAssetV1, }, diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 5ba77ddc2f..4847c84a35 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -44,8 +44,10 @@ export const newAssetRepositoryMock = (): Mocked = {}) => ({ thumbhash: null, type: AssetType.Image, visibility: AssetVisibility.Timeline, + width: null, + height: null, ...asset, }); @@ -358,6 +361,7 @@ const assetOcrFactory = ( boxScore?: number; textScore?: number; text?: string; + isVisible?: boolean; } = {}, ) => ({ id: newUuid(), @@ -373,13 +377,22 @@ const assetOcrFactory = ( boxScore: 0.95, textScore: 0.92, text: 'Sample Text', + isVisible: true, ...ocr, }); +const assetFileFactory = (file: Partial = {}): AssetFile => ({ + id: newUuid(), + type: AssetFileType.Preview, + path: '/uploads/user-id/thumbs/path.jpg', + ...file, +}); + export const factory = { activity: activityFactory, apiKey: apiKeyFactory, asset: assetFactory, + assetFile: assetFileFactory, assetOcr: assetOcrFactory, auth: authFactory, authApiKey: authApiKeyFactory, diff --git a/server/test/utils.ts b/server/test/utils.ts index 8d75752273..5a119e71f1 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -20,6 +20,7 @@ import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AppRepository } from 'src/repositories/app.repository'; +import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { AuditRepository } from 'src/repositories/audit.repository'; @@ -216,6 +217,7 @@ export type ServiceOverrides = { app: AppRepository; audit: AuditRepository; asset: AssetRepository; + assetEdit: AssetEditRepository; assetJob: AssetJobRepository; config: ConfigRepository; cron: CronRepository; @@ -289,6 +291,7 @@ export const getMocks = () => { album: automock(AlbumRepository, { strict: false }), albumUser: automock(AlbumUserRepository), asset: newAssetRepositoryMock(), + assetEdit: automock(AssetEditRepository), assetJob: automock(AssetJobRepository), app: automock(AppRepository, { strict: false }), config: newConfigRepositoryMock(), @@ -356,6 +359,7 @@ export const newTestService = ( overrides.apiKey || (mocks.apiKey as As), overrides.app || (mocks.app as As), overrides.asset || (mocks.asset as As), + overrides.assetEdit || (mocks.assetEdit as As), overrides.assetJob || (mocks.assetJob as As), overrides.audit || (mocks.audit as As), overrides.config || (mocks.config as As as ConfigRepository), diff --git a/web/package.json b/web/package.json index ef48a8a92f..415bda0014 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,7 @@ "@formatjs/icu-messageformat-parser": "^3.0.0", "@immich/justified-layout-wasm": "^0.4.3", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.53.3", + "@immich/ui": "^0.56.1", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.14.0", diff --git a/web/src/lib/components/admin-settings/AuthSettings.svelte b/web/src/lib/components/admin-settings/AuthSettings.svelte index c53060706e..098ce23259 100644 --- a/web/src/lib/components/admin-settings/AuthSettings.svelte +++ b/web/src/lib/components/admin-settings/AuthSettings.svelte @@ -32,8 +32,8 @@ const allMethodsDisabled = !configToEdit.oauth.enabled && !configToEdit.passwordLogin.enabled; if (allMethodsDisabled) { - const isConfirmed = await modalManager.show(AuthDisableLoginConfirmModal); - if (!isConfirmed) { + const confirmed = await modalManager.show(AuthDisableLoginConfirmModal); + if (!confirmed) { return false; } } diff --git a/web/src/lib/components/album-page/albums-list.svelte b/web/src/lib/components/album-page/albums-list.svelte index f6ca2b610e..f950208a16 100644 --- a/web/src/lib/components/album-page/albums-list.svelte +++ b/web/src/lib/components/album-page/albums-list.svelte @@ -6,7 +6,6 @@ import RightClickContextMenu from '$lib/components/shared-components/context-menu/right-click-context-menu.svelte'; import AlbumEditModal from '$lib/modals/AlbumEditModal.svelte'; import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte'; - import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; import { handleDeleteAlbum, handleDownloadAlbum } from '$lib/services/album.service'; import { AlbumFilter, @@ -21,14 +20,8 @@ import { userInteraction } from '$lib/stores/user.svelte'; import { getSelectedAlbumGroupOption, sortAlbums, stringToSortOrder, type AlbumGroup } from '$lib/utils/album-utils'; import type { ContextMenuPosition } from '$lib/utils/context-menu'; - import { handleError } from '$lib/utils/handle-error'; import { normalizeSearchString } from '$lib/utils/string-utils'; - import { - addUsersToAlbum, - type AlbumResponseDto, - type AlbumUserAddDto, - type SharedLinkResponseDto, - } from '@immich/sdk'; + import { type AlbumResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; import { modalManager } from '@immich/ui'; import { mdiDeleteOutline, mdiDownload, mdiRenameOutline, mdiShareVariantOutline } from '@mdi/js'; import { groupBy } from 'lodash-es'; @@ -205,18 +198,7 @@ } case 'share': { - const result = await modalManager.show(AlbumShareModal, { album: selectedAlbum }); - switch (result?.action) { - case 'sharedUsers': { - await handleAddUsers(selectedAlbum, result.data); - break; - } - - case 'sharedLink': { - await modalManager.show(SharedLinkCreateModal, { albumId: selectedAlbum.id }); - break; - } - } + await modalManager.show(AlbumShareModal, { album: selectedAlbum }); break; } @@ -251,20 +233,6 @@ sharedAlbums = findAndUpdate(sharedAlbums, album); }; - const handleAddUsers = async (album: AlbumResponseDto, albumUsers: AlbumUserAddDto[]) => { - try { - const updatedAlbum = await addUsersToAlbum({ - id: album.id, - addUsersDto: { - albumUsers, - }, - }); - onUpdate(updatedAlbum); - } catch (error) { - handleError(error, $t('errors.unable_to_add_album_users')); - } - }; - const onAlbumUpdate = (album: AlbumResponseDto) => { onUpdate(album); userInteraction.recentAlbums = findAndUpdate(userInteraction.recentAlbums || [], album); diff --git a/web/src/lib/components/asset-viewer/actions/delete-action.spec.ts b/web/src/lib/components/asset-viewer/actions/delete-action.spec.ts index 126beead9c..8ba3432464 100644 --- a/web/src/lib/components/asset-viewer/actions/delete-action.spec.ts +++ b/web/src/lib/components/asset-viewer/actions/delete-action.spec.ts @@ -7,6 +7,13 @@ import DeleteAction from './delete-action.svelte'; let asset: AssetResponseDto; describe('DeleteAction component', () => { + beforeEach(() => { + vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { trash: true } } as any }; + }); + }); + describe('given an asset which is not trashed yet', () => { beforeEach(() => { asset = assetFactory.build({ isTrashed: false }); diff --git a/web/src/lib/components/asset-viewer/actions/delete-action.svelte b/web/src/lib/components/asset-viewer/actions/delete-action.svelte index be9c9ccf9c..24ef7d941f 100644 --- a/web/src/lib/components/asset-viewer/actions/delete-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/delete-action.svelte @@ -1,15 +1,14 @@ trashOrDelete(asset.isTrashed) }, + { shortcut: { key: 'Delete' }, onShortcut: () => trashOrDelete() }, { shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) }, ]} /> @@ -75,13 +69,7 @@ color="secondary" shape="round" variant="ghost" - icon={asset.isTrashed ? mdiDeleteForeverOutline : mdiDeleteOutline} - aria-label={asset.isTrashed ? $t('permanently_delete') : $t('delete')} - onclick={() => trashOrDelete(asset.isTrashed)} + icon={forceDefault ? mdiDeleteForeverOutline : mdiDeleteOutline} + aria-label={forceDefault ? $t('permanently_delete') : $t('delete')} + onclick={() => trashOrDelete()} /> - -{#if showConfirmModal} - - (showConfirmModal = false)} onConfirm={deleteAsset} /> - -{/if} diff --git a/web/src/lib/components/asset-viewer/actions/edit-action.svelte b/web/src/lib/components/asset-viewer/actions/edit-action.svelte new file mode 100644 index 0000000000..2a630f1697 --- /dev/null +++ b/web/src/lib/components/asset-viewer/actions/edit-action.svelte @@ -0,0 +1,20 @@ + + + onAction()} +/> diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts index 957c9a17d2..1c802b0dce 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts @@ -32,8 +32,14 @@ describe('AssetViewerNavBar component', () => { vi.fn(() => ({ observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() })), ); vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { smartSearch: true } } as any }; + return { + featureFlagsManager: { + init: vi.fn(), + loadFeatureFlags: vi.fn(), + value: { trash: true, smartSearch: true }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + }; }); }); diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 60bde6e114..faa81a6e92 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -72,7 +72,7 @@ onUndoDelete?: OnUndoDelete; onRunJob: (name: AssetJobName) => void; onPlaySlideshow: () => void; - // export let showEditorHandler: () => void; + // onEdit: () => void; onClose?: () => void; playOriginalVideo: boolean; setPlayOriginalVideo: (value: boolean) => void; @@ -92,6 +92,7 @@ onRunJob, onPlaySlideshow, onClose, + // onEdit, playOriginalVideo = false, setPlayOriginalVideo, }: Props = $props(); @@ -114,15 +115,18 @@ const { Share, Download, SharedLinkDownload, Offline, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto, Info } = $derived(getAssetActions($t, asset)); - // $: showEditorButton = + // TODO: Enable when edits are ready for release + // let showEditorButton = $derived( // isOwner && - // asset.type === AssetTypeEnum.Image && - // !( - // asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || - // (asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp')) - // ) && - // !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.gif')) && - // !asset.livePhotoVideoId; + // asset.type === AssetTypeEnum.Image && + // !( + // asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || + // (asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp')) + // ) && + // !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.gif')) && + // !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.svg')) && + // !asset.livePhotoVideoId, + // ); {/if} + + {#if isOwner} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 27fe0f8c74..118d1a52f8 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -10,8 +10,8 @@ import { activityManager } from '$lib/managers/activity-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; + import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte'; import { preloadManager } from '$lib/managers/PreloadManager.svelte'; - import { closeEditorCofirm } from '$lib/stores/asset-editor.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { ocrManager } from '$lib/stores/ocr.svelte'; import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store'; @@ -44,8 +44,8 @@ import ActivityStatus from './activity-status.svelte'; import ActivityViewer from './activity-viewer.svelte'; import DetailPanel from './detail-panel.svelte'; - import CropArea from './editor/crop-tool/crop-area.svelte'; import EditorPanel from './editor/editor-panel.svelte'; + import CropArea from './editor/transform-tool/crop-area.svelte'; import ImagePanoramaViewer from './image-panorama-viewer.svelte'; import OcrButton from './ocr-button.svelte'; import PhotoViewer from './photo-viewer.svelte'; @@ -67,6 +67,7 @@ isShared?: boolean; album?: AlbumResponseDto; person?: PersonResponseDto; + onAssetChange?: (asset: AssetResponseDto) => void; preAction?: PreAction; onAction?: OnAction; onUndoDelete?: OnUndoDelete; @@ -84,6 +85,7 @@ isShared = false, album, person, + onAssetChange, preAction, onAction, onUndoDelete, @@ -105,14 +107,13 @@ const stackThumbnailSize = 60; const stackSelectedThumbnailSize = 65; - let asset = $derived(cursor.current); + const asset = $derived(cursor.current); let appearsInAlbums: AlbumResponseDto[] = $state([]); let sharedLink = getSharedLink(); let previewStackedAsset: AssetResponseDto | undefined = $state(); let isShowEditor = $state(false); let fullscreenElement = $state(); let unsubscribes: (() => void)[] = []; - let selectedEditType: string = $state(''); let stack: StackResponseDto | null = $state(null); let zoomToggle = $state(() => void 0); @@ -200,10 +201,15 @@ onClose?.(asset); }; - const closeEditor = () => { - closeEditorCofirm(() => { - isShowEditor = false; - }); + const closeEditor = async () => { + if (editManager.hasAppliedEdits) { + console.log(asset); + const refreshedAsset = await getAssetInfo({ id: asset.id }); + console.log(refreshedAsset); + onAssetChange?.(refreshedAsset); + assetViewingStore.setAsset(refreshedAsset); + } + isShowEditor = false; }; const tracker = new InvocationTracker(); @@ -249,6 +255,13 @@ }); }; + // const showEditor = () => { + // if (assetViewerManager.isShowActivityPanel) { + // assetViewerManager.isShowActivityPanel = false; + // } + // isShowEditor = !isShowEditor; + // }; + const handleRunJob = async (name: AssetJobName) => { try { await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } }); @@ -312,7 +325,7 @@ case AssetAction.REMOVE_ASSET_FROM_STACK: { stack = action.stack; if (stack) { - asset = stack.assets[0]; + cursor.current = stack.assets[0]; } break; } @@ -323,11 +336,11 @@ } case AssetAction.SET_PERSON_FEATURED_PHOTO: { const assetInfo = await getAssetInfo({ id: asset.id }); - asset = { ...asset, people: assetInfo.people }; + cursor.current = { ...asset, people: assetInfo.people }; break; } case AssetAction.RATING: { - asset = { + cursor.current = { ...asset, exifInfo: { ...asset.exifInfo, @@ -346,10 +359,6 @@ onAction?.(action); }; - const handleUpdateSelectedEditType = (type: string) => { - selectedEditType = type; - }; - let isFullScreen = $derived(fullscreenElement !== null); $effect(() => { @@ -394,7 +403,7 @@ const onAssetUpdate = (update: AssetResponseDto) => { if (asset.id === update.id) { - asset = update; + cursor.current = update; } }; @@ -498,7 +507,7 @@ .toLowerCase() .endsWith('.insp'))} - {:else if isShowEditor && selectedEditType === 'crop'} + {:else if isShowEditor && editManager.selectedTool?.type === EditToolType.Transform} {:else} - + {/if} @@ -590,7 +599,7 @@ dimmed={stackedAsset.id !== asset.id} asset={toTimelineAsset(stackedAsset)} onClick={() => { - asset = stackedAsset; + cursor.current = stackedAsset; previewStackedAsset = undefined; }} onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)} diff --git a/web/src/lib/components/asset-viewer/detail-panel-description.svelte b/web/src/lib/components/asset-viewer/detail-panel-description.svelte index b41891de82..a1ffd8441b 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-description.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-description.svelte @@ -13,17 +13,16 @@ let { asset, isOwner }: Props = $props(); - let currentDescription = asset.exifInfo?.description ?? ''; - let draftDescription = $state(currentDescription); + let currentDescription = $derived(asset.exifInfo?.description ?? ''); + let description = $derived(currentDescription); const handleFocusOut = async () => { - if (draftDescription === currentDescription) { + if (description === currentDescription) { return; } try { - await updateAsset({ id: asset.id, updateAssetDto: { description: draftDescription } }); + await updateAsset({ id: asset.id, updateAssetDto: { description } }); toastManager.success($t('asset_description_updated')); - currentDescription = draftDescription; } catch (error) { handleError(error, $t('cannot_update_the_description')); } @@ -33,7 +32,7 @@ {#if isOwner}