Compare commits

..

3 Commits

Author SHA1 Message Date
midzelis 137ef8cf51 feat(web): add zoom minimap preview overlay
Change-Id: I51d4defc2ad2b7853ec0a4944684a56f6a6a6964
2026-03-24 15:24:12 +00:00
midzelis 590a9df7ec feat(web): face overlay hover UX and face editor zoom preservation
Change-Id: I7164305d7764bec54fa06b8738cd97fd6a6a6964

refactor(web): use asset metadata for face editor image dimensions instead of DOM

The face editor previously read naturalWidth/naturalHeight from the DOM
element via a $effect + load event listener. This was fragile on slow
hardware (ARM CI) because imgRef changes as AdaptiveImage progresses
through quality levels, and the DOM element's natural dimensions could
be 0 during transitions.

Now the face editor receives imageSize as a prop from the parent, derived
from the asset's metadata dimensions which are always available immediately.

Change-Id: Id4c3a59110feff4c50f429bbd744eac46a6a6964
Change-Id: I7164305d7764bec54fa06b8738cd97fd6a6a6964
2026-03-24 15:24:12 +00:00
midzelis ed04d87273 feat(web): OCR overlay interactivity during zoom
Change-Id: Id62e1a0264df2de0f3177a59b24bc5176a6a6964
2026-03-23 13:16:08 +00:00
115 changed files with 3152 additions and 2392 deletions
-143
View File
@@ -1,143 +0,0 @@
name: Auto-close PRs
on:
pull_request_target: # zizmor: ignore[dangerous-triggers]
types: [opened, edited, labeled]
permissions: {}
jobs:
parse_template:
runs-on: ubuntu-latest
if: ${{ github.event.action != 'labeled' && github.event.pull_request.head.repo.fork == true }}
permissions:
contents: read
outputs:
uses_template: ${{ steps.check.outputs.uses_template }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: .github/pull_request_template.md
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Check required sections
id: check
env:
BODY: ${{ github.event.pull_request.body }}
run: |
OK=true
while IFS= read -r header; do
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
echo "uses_template=$OK" >> "$GITHUB_OUTPUT"
close_template:
runs-on: ubuntu-latest
needs: parse_template
if: ${{ needs.parse_template.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }}
permissions:
pull-requests: write
steps:
- name: Comment and close
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
-f query='
mutation CommentAndClosePR($prId: ID!, $body: String!) {
addComment(input: {
subjectId: $prId,
body: $body
}) {
__typename
}
closePullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'
- name: Add label
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: gh pr edit "$PR_NUMBER" --add-label "auto-closed:template"
close_llm:
runs-on: ubuntu-latest
if: ${{ github.event.action == 'labeled' && github.event.label.name == 'auto-closed:llm' }}
permissions:
pull-requests: write
steps:
- name: Comment and close
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f body="Thank you for your interest in contributing to Immich! Unfortunately this PR looks like it was generated using an LLM. As noted in our [CONTRIBUTING.md](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md#use-of-generative-ai), we request that you don't use LLMs to generate PRs as those are not a good use of maintainer time." \
-f query='
mutation CommentAndClosePR($prId: ID!, $body: String!) {
addComment(input: {
subjectId: $prId,
body: $body
}) {
__typename
}
closePullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'
reopen:
runs-on: ubuntu-latest
needs: parse_template
if: >-
${{
needs.parse_template.outputs.uses_template == 'true'
&& github.event.pull_request.state == 'closed'
&& contains(github.event.pull_request.labels.*.name, 'auto-closed:template')
}}
permissions:
pull-requests: write
steps:
- name: Remove template label
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: gh pr edit "$PR_NUMBER" --remove-label "auto-closed:template" || true
- name: Check for remaining auto-closed labels
id: check_labels
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
REMAINING=$(gh pr view "$PR_NUMBER" --json labels \
--jq '[.labels[].name | select(startswith("auto-closed:"))] | length')
echo "remaining=$REMAINING" >> "$GITHUB_OUTPUT"
- name: Reopen PR
if: ${{ steps.check_labels.outputs.remaining == '0' }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f query='
mutation ReopenPR($prId: ID!) {
reopenPullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'
+11 -11
View File
@@ -51,14 +51,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }} should_run: ${{ steps.check.outputs.should_run }}
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run - name: Check what should run
id: check id: check
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3 uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
with: with:
github-token: ${{ steps.token.outputs.token }} github-token: ${{ steps.token.outputs.token }}
filters: | filters: |
@@ -79,7 +79,7 @@ jobs:
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -103,7 +103,7 @@ jobs:
- name: Restore Gradle Cache - name: Restore Gradle Cache
id: cache-gradle-restore id: cache-gradle-restore
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -114,7 +114,7 @@ jobs:
key: build-mobile-gradle-${{ runner.os }}-main key: build-mobile-gradle-${{ runner.os }}-main
- name: Setup Flutter SDK - name: Setup Flutter SDK
uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0 uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
with: with:
channel: 'stable' channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml flutter-version-file: ./mobile/pubspec.yaml
@@ -153,14 +153,14 @@ jobs:
fi fi
- name: Publish Android Artifact - name: Publish Android Artifact
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with: with:
name: release-apk-signed name: release-apk-signed
path: mobile/build/app/outputs/flutter-apk/*.apk path: mobile/build/app/outputs/flutter-apk/*.apk
- name: Save Gradle Cache - name: Save Gradle Cache
id: cache-gradle-save id: cache-gradle-save
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
with: with:
path: | path: |
@@ -185,13 +185,13 @@ jobs:
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
- name: Checkout code - name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with: with:
ref: ${{ inputs.ref || github.sha }} ref: ${{ inputs.ref || github.sha }}
persist-credentials: false persist-credentials: false
- name: Setup Flutter SDK - name: Setup Flutter SDK
uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0 uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2
with: with:
channel: 'stable' channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml flutter-version-file: ./mobile/pubspec.yaml
@@ -210,7 +210,7 @@ jobs:
working-directory: ./mobile working-directory: ./mobile
- name: Setup Ruby - name: Setup Ruby
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 uses: ruby/setup-ruby@v1
with: with:
ruby-version: '3.3' ruby-version: '3.3'
bundler-cache: true bundler-cache: true
@@ -291,7 +291,7 @@ jobs:
security delete-keychain build.keychain || true security delete-keychain build.keychain || true
- name: Upload IPA artifact - name: Upload IPA artifact
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with: with:
name: ios-release-ipa name: ios-release-ipa
path: mobile/ios/Runner.ipa path: mobile/ios/Runner.ipa
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
actions: write actions: write
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Check for breaking API changes - name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@2a37bc82462349c03a533b8b608bebbaf57b3e60 # v0.0.33 uses: oasdiff/oasdiff-action/breaking@748daafaf3aac877a36307f842a48d55db938ac8 # v0.0.31
with: with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json revision: open-api/immich-openapi-specs.json
+97
View File
@@ -0,0 +1,97 @@
name: Check PR Template
on:
pull_request_target: # zizmor: ignore[dangerous-triggers]
types: [opened, edited]
permissions: {}
env:
LABEL_ID: 'LA_kwDOGyI-8M8AAAACcAeOfg' # auto-closed:template
jobs:
parse:
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.head.repo.fork == true }}
permissions:
contents: read
outputs:
uses_template: ${{ steps.check.outputs.uses_template }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: .github/pull_request_template.md
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Check required sections
id: check
env:
BODY: ${{ github.event.pull_request.body }}
run: |
OK=true
while IFS= read -r header; do
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
echo "uses_template=$OK" >> "$GITHUB_OUTPUT"
act:
runs-on: ubuntu-latest
needs: parse
permissions:
pull-requests: write
steps:
- name: Close PR
if: ${{ needs.parse.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f labelId="$LABEL_ID" \
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
-f query='
mutation CommentAndClosePR($prId: ID!, $body: String!, $labelId: ID!) {
addComment(input: {
subjectId: $prId,
body: $body
}) {
__typename
}
closePullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
addLabelsToLabelable(input: {
labelableId: $prId,
labelIds: [$labelId]
}) {
__typename
}
}'
- name: Reopen PR (sections now present, PR was auto-closed)
if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' && contains(github.event.pull_request.labels.*.node_id, env.LABEL_ID) }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f labelId="$LABEL_ID" \
-f query='
mutation ReopenPR($prId: ID!, $labelId: ID!) {
reopenPullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
removeLabelsFromLabelable(input: {
labelableId: $prId,
labelIds: [$labelId]
}) {
__typename
}
}'
+8 -8
View File
@@ -31,7 +31,7 @@ jobs:
working-directory: ./cli working-directory: ./cli
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -42,7 +42,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -71,7 +71,7 @@ jobs:
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -83,13 +83,13 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
if: ${{ !github.event.pull_request.head.repo.fork }} if: ${{ !github.event.pull_request.head.repo.fork }}
with: with:
registry: ghcr.io registry: ghcr.io
@@ -104,7 +104,7 @@ jobs:
- name: Generate docker image tags - name: Generate docker image tags
id: metadata id: metadata
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with: with:
flavor: | flavor: |
latest=false latest=false
@@ -115,7 +115,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'release' }} type=raw,value=latest,enable=${{ github.event_name == 'release' }}
- name: Build and push image - name: Build and push image
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with: with:
file: cli/Dockerfile file: cli/Dockerfile
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
needs: [get_body, should_run] needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }} if: ${{ needs.should_run.outputs.should_run == 'true' }}
container: container:
image: ghcr.io/immich-app/mdq:main@sha256:df7188ba88abb0800d73cc97d3633280f0c0c3d4c441d678225067bf154150fb image: ghcr.io/immich-app/mdq:main@sha256:4f9860d04c88f7f87861f8ee84bfeedaec15ed7ca5ca87bc7db44b036f81645f
outputs: outputs:
checked: ${{ steps.get_checkbox.outputs.checked }} checked: ${{ steps.get_checkbox.outputs.checked }}
steps: steps:
+38
View File
@@ -0,0 +1,38 @@
name: Close LLM-generated PRs
on:
pull_request_target:
types: [labeled]
permissions: {}
jobs:
comment_and_close:
runs-on: ubuntu-latest
if: ${{ github.event.label.name == 'llm-generated' }}
permissions:
pull-requests: write
steps:
- name: Comment and close
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f body="Thank you for your interest in contributing to Immich! Unfortunately this PR looks like it was generated using an LLM. As noted in our [CONTRIBUTING.md](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md#use-of-generative-ai), we request that you don't use LLMs to generate PRs as those are not a good use of maintainer time." \
-f query='
mutation CommentAndClosePR($prId: ID!, $body: String!) {
addComment(input: {
subjectId: $prId,
body: $body
}) {
__typename
}
closePullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'
+4 -4
View File
@@ -44,7 +44,7 @@ jobs:
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0 uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0 uses: github/codeql-action/autobuild@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
# ️ Command-line programs to run using the OS shell. # ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh # ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0 uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
with: with:
category: '/language:${{matrix.language}}' category: '/language:${{matrix.language}}'
+6 -6
View File
@@ -23,14 +23,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }} should_run: ${{ steps.check.outputs.should_run }}
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run - name: Check what should run
id: check id: check
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3 uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
with: with:
github-token: ${{ steps.token.outputs.token }} github-token: ${{ steps.token.outputs.token }}
filters: | filters: |
@@ -60,7 +60,7 @@ jobs:
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn'] suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
steps: steps:
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -90,7 +90,7 @@ jobs:
suffix: [''] suffix: ['']
steps: steps:
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -132,7 +132,7 @@ jobs:
suffixes: '-rocm' suffixes: '-rocm'
platforms: linux/amd64 platforms: linux/amd64
runner-mapping: '{"linux/amd64": "pokedex-large"}' runner-mapping: '{"linux/amd64": "pokedex-large"}'
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@61a0fc2b41524edcc7c9fffb8bb178e6b0ccf21d # multi-runner-build-workflow-v2.3.0 uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
permissions: permissions:
contents: read contents: read
actions: read actions: read
@@ -155,7 +155,7 @@ jobs:
name: Build and Push Server name: Build and Push Server
needs: pre-job needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }} if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@61a0fc2b41524edcc7c9fffb8bb178e6b0ccf21d # multi-runner-build-workflow-v2.3.0 uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
permissions: permissions:
contents: read contents: read
actions: read actions: read
+5 -5
View File
@@ -21,14 +21,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }} should_run: ${{ steps.check.outputs.should_run }}
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run - name: Check what should run
id: check id: check
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3 uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
with: with:
github-token: ${{ steps.token.outputs.token }} github-token: ${{ steps.token.outputs.token }}
filters: | filters: |
@@ -54,7 +54,7 @@ jobs:
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -67,7 +67,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -86,7 +86,7 @@ jobs:
run: pnpm build run: pnpm build
- name: Upload build output - name: Upload build output
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with: with:
name: docs-build-output name: docs-build-output
path: docs/build/ path: docs/build/
+3 -3
View File
@@ -20,7 +20,7 @@ jobs:
artifact: ${{ steps.get-artifact.outputs.result }} artifact: ${{ steps.get-artifact.outputs.result }}
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -119,7 +119,7 @@ jobs:
if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }} if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }}
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -131,7 +131,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@035e80a7d4355d5f087ffb95db9e4a0944c04e56 # use-mise-action-v1.1.3 uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2
- name: Load parameters - name: Load parameters
id: parameters id: parameters
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -29,7 +29,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@035e80a7d4355d5f087ffb95db9e4a0944c04e56 # use-mise-action-v1.1.3 uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2
- name: Destroy Docs Subdomain - name: Destroy Docs Subdomain
env: env:
+2 -2
View File
@@ -16,7 +16,7 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -29,7 +29,7 @@ jobs:
persist-credentials: true persist-credentials: true
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: Generate a token - name: Generate a token
id: generate_token id: generate_token
if: ${{ inputs.skip != true }} if: ${{ inputs.skip != true }}
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+2 -2
View File
@@ -14,13 +14,13 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Require PR to have a changelog label - name: Require PR to have a changelog label
uses: mheap/github-action-required-labels@0ac283b4e65c1fb28ce6079dea5546ceca98ccbe # v5.5.2 uses: mheap/github-action-required-labels@8afbe8ae6ab7647d0c9f0cfa7c2f939650d22509 # v5.5.1
with: with:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
mode: exactly mode: exactly
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+6 -6
View File
@@ -50,7 +50,7 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -63,10 +63,10 @@ jobs:
ref: main ref: main
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -124,7 +124,7 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -136,13 +136,13 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Download APK - name: Download APK
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with: with:
name: release-apk-signed name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }} github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release - name: Create draft release
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with: with:
draft: true draft: true
tag_name: ${{ needs.bump_version.outputs.version }} tag_name: ${{ needs.bump_version.outputs.version }}
+5 -5
View File
@@ -14,12 +14,12 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: mshick/add-pr-comment@ffd016c7e151d97d69d21a843022fd4cd5b96fe5 # v3.9.0 - uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
with: with:
github-token: ${{ steps.token.outputs.token }} github-token: ${{ steps.token.outputs.token }}
message-id: 'preview-status' message-id: 'preview-status'
@@ -32,7 +32,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -48,14 +48,14 @@ jobs:
name: 'preview' name: 'preview'
}) })
- uses: mshick/add-pr-comment@ffd016c7e151d97d69d21a843022fd4cd5b96fe5 # v3.9.0 - uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
if: ${{ github.event.pull_request.head.repo.fork }} if: ${{ github.event.pull_request.head.repo.fork }}
with: with:
github-token: ${{ steps.token.outputs.token }} github-token: ${{ steps.token.outputs.token }}
message-id: 'preview-status' message-id: 'preview-status'
message: 'PRs from forks cannot have preview environments.' message: 'PRs from forks cannot have preview environments.'
- uses: mshick/add-pr-comment@ffd016c7e151d97d69d21a843022fd4cd5b96fe5 # v3.9.0 - uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
if: ${{ !github.event.pull_request.head.repo.fork }} if: ${{ !github.event.pull_request.head.repo.fork }}
with: with:
github-token: ${{ steps.token.outputs.token }} github-token: ${{ steps.token.outputs.token }}
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
working-directory: ./open-api/typescript-sdk working-directory: ./open-api/typescript-sdk
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -30,7 +30,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
# Setup .npmrc file to publish to npm # Setup .npmrc file to publish to npm
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+4 -4
View File
@@ -20,14 +20,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }} should_run: ${{ steps.check.outputs.should_run }}
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run - name: Check what should run
id: check id: check
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3 uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
with: with:
github-token: ${{ steps.token.outputs.token }} github-token: ${{ steps.token.outputs.token }}
filters: | filters: |
@@ -49,7 +49,7 @@ jobs:
working-directory: ./mobile working-directory: ./mobile
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -61,7 +61,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Flutter SDK - name: Setup Flutter SDK
uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0 uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
with: with:
channel: 'stable' channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml flutter-version-file: ./mobile/pubspec.yaml
+38 -38
View File
@@ -17,14 +17,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }} should_run: ${{ steps.check.outputs.should_run }}
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run - name: Check what should run
id: check id: check
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3 uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
with: with:
github-token: ${{ steps.token.outputs.token }} github-token: ${{ steps.token.outputs.token }}
filters: | filters: |
@@ -63,7 +63,7 @@ jobs:
working-directory: ./server working-directory: ./server
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -75,7 +75,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
@@ -108,7 +108,7 @@ jobs:
working-directory: ./cli working-directory: ./cli
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -119,7 +119,7 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
@@ -155,7 +155,7 @@ jobs:
working-directory: ./cli working-directory: ./cli
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -166,7 +166,7 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
@@ -197,7 +197,7 @@ jobs:
working-directory: ./web working-directory: ./web
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -208,7 +208,7 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
@@ -241,7 +241,7 @@ jobs:
working-directory: ./web working-directory: ./web
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -252,7 +252,7 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
@@ -279,7 +279,7 @@ jobs:
contents: read contents: read
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -290,7 +290,7 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
@@ -327,7 +327,7 @@ jobs:
working-directory: ./e2e working-directory: ./e2e
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -338,7 +338,7 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
@@ -373,7 +373,7 @@ jobs:
working-directory: ./server working-directory: ./server
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -385,7 +385,7 @@ jobs:
submodules: 'recursive' submodules: 'recursive'
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
@@ -412,7 +412,7 @@ jobs:
runner: [ubuntu-latest, ubuntu-24.04-arm] runner: [ubuntu-latest, ubuntu-24.04-arm]
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -424,7 +424,7 @@ jobs:
submodules: 'recursive' submodules: 'recursive'
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
@@ -464,7 +464,7 @@ jobs:
run: docker compose logs --no-color > docker-compose-logs.txt run: docker compose logs --no-color > docker-compose-logs.txt
working-directory: ./e2e working-directory: ./e2e
- name: Archive Docker logs - name: Archive Docker logs
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: always() if: always()
with: with:
name: e2e-server-docker-logs-${{ matrix.runner }} name: e2e-server-docker-logs-${{ matrix.runner }}
@@ -484,7 +484,7 @@ jobs:
runner: [ubuntu-latest, ubuntu-24.04-arm] runner: [ubuntu-latest, ubuntu-24.04-arm]
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -496,7 +496,7 @@ jobs:
submodules: 'recursive' submodules: 'recursive'
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
@@ -522,7 +522,7 @@ jobs:
run: pnpm test:web run: pnpm test:web
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Archive e2e test (web) results - name: Archive e2e test (web) results
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: success() || failure() if: success() || failure()
with: with:
name: e2e-web-test-results-${{ matrix.runner }} name: e2e-web-test-results-${{ matrix.runner }}
@@ -533,7 +533,7 @@ jobs:
run: pnpm test:web:ui run: pnpm test:web:ui
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Archive ui test (web) results - name: Archive ui test (web) results
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: success() || failure() if: success() || failure()
with: with:
name: e2e-ui-test-results-${{ matrix.runner }} name: e2e-ui-test-results-${{ matrix.runner }}
@@ -544,7 +544,7 @@ jobs:
run: pnpm test:web:maintenance run: pnpm test:web:maintenance
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Archive maintenance tests (web) results - name: Archive maintenance tests (web) results
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: success() || failure() if: success() || failure()
with: with:
name: e2e-maintenance-isolated-test-results-${{ matrix.runner }} name: e2e-maintenance-isolated-test-results-${{ matrix.runner }}
@@ -554,7 +554,7 @@ jobs:
run: docker compose logs --no-color > docker-compose-logs.txt run: docker compose logs --no-color > docker-compose-logs.txt
working-directory: ./e2e working-directory: ./e2e
- name: Archive Docker logs - name: Archive Docker logs
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: always() if: always()
with: with:
name: e2e-web-docker-logs-${{ matrix.runner }} name: e2e-web-docker-logs-${{ matrix.runner }}
@@ -578,7 +578,7 @@ jobs:
contents: read contents: read
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -588,7 +588,7 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Flutter SDK - name: Setup Flutter SDK
uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0 uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
with: with:
channel: 'stable' channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml flutter-version-file: ./mobile/pubspec.yaml
@@ -610,7 +610,7 @@ jobs:
working-directory: ./machine-learning working-directory: ./machine-learning
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -620,7 +620,7 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
with: with:
python-version: 3.11 python-version: 3.11
- name: Install dependencies - name: Install dependencies
@@ -650,7 +650,7 @@ jobs:
working-directory: ./.github working-directory: ./.github
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -661,7 +661,7 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
@@ -680,7 +680,7 @@ jobs:
contents: read contents: read
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -701,7 +701,7 @@ jobs:
contents: read contents: read
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -712,7 +712,7 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
@@ -763,7 +763,7 @@ jobs:
working-directory: ./server working-directory: ./server
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -774,7 +774,7 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
+3 -3
View File
@@ -24,14 +24,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }} should_run: ${{ steps.check.outputs.should_run }}
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run - name: Check what should run
id: check id: check
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3 uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
with: with:
github-token: ${{ steps.token.outputs.token }} github-token: ${{ steps.token.outputs.token }}
filters: | filters: |
@@ -47,7 +47,7 @@ jobs:
if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }} if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
steps: steps:
- id: token - id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2 uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.6.2", "version": "2.6.1",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"type": "module", "type": "module",
"exports": "./dist/index.js", "exports": "./dist/index.js",
@@ -35,7 +35,8 @@
"prettier-plugin-organize-imports": "^4.0.0", "prettier-plugin-organize-imports": "^4.0.0",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"typescript-eslint": "^8.28.0", "typescript-eslint": "^8.28.0",
"vite": "^8.0.0", "vite": "^7.0.0",
"vite-tsconfig-paths": "^6.0.0",
"vitest": "^4.0.0", "vitest": "^4.0.0",
"vitest-fetch-mock": "^0.4.0", "vitest-fetch-mock": "^0.4.0",
"yaml": "^2.3.1" "yaml": "^2.3.1"
+4 -5
View File
@@ -1,12 +1,10 @@
import { defineConfig, UserConfig } from 'vite'; import { defineConfig, UserConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({ export default defineConfig({
resolve: { resolve: { alias: { src: '/src' } },
alias: { src: '/src' },
tsconfigPaths: true,
},
build: { build: {
rolldownOptions: { rollupOptions: {
input: 'src/index.ts', input: 'src/index.ts',
output: { output: {
dir: 'dist', dir: 'dist',
@@ -18,6 +16,7 @@ export default defineConfig({
// bundle everything except for Node built-ins // bundle everything except for Node built-ins
noExternal: /^(?!node:).*$/, noExternal: /^(?!node:).*$/,
}, },
plugins: [tsconfigPaths()],
test: { test: {
name: 'cli:unit', name: 'cli:unit',
globals: true, globals: true,
+1 -5
View File
@@ -8,7 +8,7 @@ Hardware and software requirements for Immich:
## Hardware ## Hardware
- **OS**: Recommended Linux or \*nix 64-bit operating system (Ubuntu, Debian, etc). - **OS**: Recommended Linux or \*nix operating system (Ubuntu, Debian, etc).
- Non-Linux OSes tend to provide a poor Docker experience and are strongly discouraged. - Non-Linux OSes tend to provide a poor Docker experience and are strongly discouraged.
Our ability to assist with setup or troubleshooting on non-Linux OSes will be severely reduced. Our ability to assist with setup or troubleshooting on non-Linux OSes will be severely reduced.
If you still want to try to use a non-Linux OS, you can set it up as follows: If you still want to try to use a non-Linux OS, you can set it up as follows:
@@ -19,10 +19,6 @@ Hardware and software requirements for Immich:
If you have issues, we recommend that you switch to a supported VM deployment. If you have issues, we recommend that you switch to a supported VM deployment.
- **RAM**: Minimum 6GB, recommended 8GB. - **RAM**: Minimum 6GB, recommended 8GB.
- **CPU**: Minimum 2 cores, recommended 4 cores. - **CPU**: Minimum 2 cores, recommended 4 cores.
- Immich runs on the `amd64` and `arm64` platforms.
Since `v2.6`, the machine learning container on `amd64` requires the `>= x86-64-v2` [microarchitecture level](https://en.wikipedia.org/wiki/X86-64#Microarchitecture_levels).
Most CPUs released since ~2012 support this microarchitecture.
If you are using a virtual machine, ensure you have selected a [supported microarchitecture](https://pve.proxmox.com/pve-docs/chapter-qm.html#_qemu_cpu_types).
- **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions. - **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions.
- The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average. - The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average.
+2 -2
View File
@@ -1,7 +1,7 @@
[ [
{ {
"label": "v2.6.2", "label": "v2.6.1",
"url": "https://docs.v2.6.2.archive.immich.app" "url": "https://docs.v2.6.1.archive.immich.app"
}, },
{ {
"label": "v2.5.6", "label": "v2.5.6",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "2.6.2", "version": "2.6.1",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
+3 -8
View File
@@ -524,19 +524,14 @@ describe('/albums', () => {
expect(body).toEqual(errorDto.badRequest('Not found or no album.update access')); expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
}); });
it('should be able to update as an editor', async () => { it('should not be able to update as an editor', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.patch(`/albums/${user1Albums[0].id}`) .patch(`/albums/${user1Albums[0].id}`)
.set('Authorization', `Bearer ${user2.accessToken}`) .set('Authorization', `Bearer ${user2.accessToken}`)
.send({ albumName: 'New album name' }); .send({ albumName: 'New album name' });
expect(status).toBe(200); expect(status).toBe(400);
expect(body).toEqual( expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
expect.objectContaining({
id: user1Albums[0].id,
albumName: 'New album name',
}),
);
}); });
}); });
-51
View File
@@ -1,51 +0,0 @@
import { AssetMediaResponseDto, LoginResponseDto, updateAssets } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import crypto from 'node:crypto';
import { asBearerAuth, utils } from 'src/utils';
test.describe('Duplicates Utility', () => {
let admin: LoginResponseDto;
let firstAsset: AssetMediaResponseDto;
let secondAsset: AssetMediaResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
});
test.beforeEach(async ({ context }) => {
[firstAsset, secondAsset] = await Promise.all([
utils.createAsset(admin.accessToken, { deviceAssetId: 'duplicate-a' }),
utils.createAsset(admin.accessToken, { deviceAssetId: 'duplicate-b' }),
]);
await updateAssets(
{
assetBulkUpdateDto: {
ids: [firstAsset.id, secondAsset.id],
duplicateId: crypto.randomUUID(),
},
},
{ headers: asBearerAuth(admin.accessToken) },
);
await utils.setAuthCookies(context, admin.accessToken);
});
test('navigates with arrow keys between duplicate preview assets', async ({ page }) => {
await page.goto('/utilities/duplicates');
await page.getByRole('button', { name: 'View' }).first().click();
await page.waitForSelector('#immich-asset-viewer');
const getViewedAssetId = () => new URL(page.url()).pathname.split('/').at(-1) ?? '';
const initialAssetId = getViewedAssetId();
expect([firstAsset.id, secondAsset.id]).toContain(initialAssetId);
await page.keyboard.press('ArrowRight');
await expect.poll(getViewedAssetId).not.toBe(initialAssetId);
await page.keyboard.press('ArrowLeft');
await expect.poll(getViewedAssetId).toBe(initialAssetId);
});
});
+1 -1
View File
@@ -20,7 +20,7 @@ export {
toColumnarFormat, toColumnarFormat,
} from './timeline/rest-response'; } from './timeline/rest-response';
export type { Changes } from './timeline/rest-response'; export type { Changes, FaceData } from './timeline/rest-response';
export { randomImage, randomImageFromString, randomPreview, randomThumbnail } from './timeline/images'; export { randomImage, randomImageFromString, randomPreview, randomThumbnail } from './timeline/images';
@@ -7,8 +7,10 @@ import {
AssetVisibility, AssetVisibility,
UserAvatarColor, UserAvatarColor,
type AlbumResponseDto, type AlbumResponseDto,
type AssetFaceWithoutPersonResponseDto,
type AssetResponseDto, type AssetResponseDto,
type ExifResponseDto, type ExifResponseDto,
type PersonWithFacesResponseDto,
type TimeBucketAssetResponseDto, type TimeBucketAssetResponseDto,
type TimeBucketsResponseDto, type TimeBucketsResponseDto,
type UserResponseDto, type UserResponseDto,
@@ -284,7 +286,16 @@ const createDefaultOwner = (ownerId: string) => {
* Convert a TimelineAssetConfig to a full AssetResponseDto * Convert a TimelineAssetConfig to a full AssetResponseDto
* This matches the response from GET /api/assets/:id * This matches the response from GET /api/assets/:id
*/ */
export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserResponseDto): AssetResponseDto { export type FaceData = {
people: PersonWithFacesResponseDto[];
unassignedFaces: AssetFaceWithoutPersonResponseDto[];
};
export function toAssetResponseDto(
asset: MockTimelineAsset,
owner?: UserResponseDto,
faceData?: FaceData,
): AssetResponseDto {
const now = new Date().toISOString(); const now = new Date().toISOString();
// Default owner if not provided // Default owner if not provided
@@ -338,8 +349,8 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
exifInfo, exifInfo,
livePhotoVideoId: asset.livePhotoVideoId, livePhotoVideoId: asset.livePhotoVideoId,
tags: [], tags: [],
people: [], people: faceData?.people ?? [],
unassignedFaces: [], unassignedFaces: faceData?.unassignedFaces ?? [],
stack: asset.stack, stack: asset.stack,
isOffline: false, isOffline: false,
hasMetadata: true, hasMetadata: true,
+83 -1
View File
@@ -1,5 +1,6 @@
import type { AssetFaceResponseDto, AssetResponseDto, PersonWithFacesResponseDto, SourceType } from '@immich/sdk';
import { BrowserContext } from '@playwright/test'; import { BrowserContext } from '@playwright/test';
import { randomThumbnail } from 'src/ui/generators/timeline'; import { type FaceData, randomThumbnail } from 'src/ui/generators/timeline';
// Minimal valid H.264 MP4 (8x8px, 1 frame) that browsers can decode to get videoWidth/videoHeight // Minimal valid H.264 MP4 (8x8px, 1 frame) that browsers can decode to get videoWidth/videoHeight
const MINIMAL_MP4_BASE64 = const MINIMAL_MP4_BASE64 =
@@ -125,3 +126,84 @@ export const setupFaceEditorMockApiRoutes = async (
}); });
}); });
}; };
export type MockFaceSpec = {
personId: string;
personName: string;
faceId: string;
boundingBoxX1: number;
boundingBoxY1: number;
boundingBoxX2: number;
boundingBoxY2: number;
};
const toPersonResponseDto = (spec: MockFaceSpec) => ({
id: spec.personId,
name: spec.personName,
birthDate: null,
isHidden: false,
thumbnailPath: `/upload/thumbs/${spec.personId}.jpeg`,
updatedAt: '2025-01-01T00:00:00.000Z',
});
const toBoundingBox = (spec: MockFaceSpec, imageWidth: number, imageHeight: number) => ({
id: spec.faceId,
imageWidth,
imageHeight,
boundingBoxX1: spec.boundingBoxX1,
boundingBoxY1: spec.boundingBoxY1,
boundingBoxX2: spec.boundingBoxX2,
boundingBoxY2: spec.boundingBoxY2,
});
export const createMockFaceData = (specs: MockFaceSpec[], imageWidth: number, imageHeight: number): FaceData => {
const people: PersonWithFacesResponseDto[] = specs.map((spec) => ({
...toPersonResponseDto(spec),
faces: [toBoundingBox(spec, imageWidth, imageHeight)],
}));
return { people, unassignedFaces: [] };
};
export const createMockAssetFaces = (
specs: MockFaceSpec[],
imageWidth: number,
imageHeight: number,
): AssetFaceResponseDto[] => {
return specs.map((spec) => ({
...toBoundingBox(spec, imageWidth, imageHeight),
person: toPersonResponseDto(spec),
sourceType: 'machine-learning' as SourceType,
}));
};
export const setupGetFacesMockApiRoute = async (context: BrowserContext, faces: AssetFaceResponseDto[]) => {
await context.route('**/api/faces?*', async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
return route.fulfill({
status: 200,
contentType: 'application/json',
json: faces,
});
});
};
export const setupFaceOverlayMockApiRoutes = async (context: BrowserContext, assetDto: AssetResponseDto) => {
await context.route('**/api/assets/*', async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
const url = new URL(request.url());
const assetId = url.pathname.split('/').at(-1);
if (assetId !== assetDto.id) {
return route.fallback();
}
return route.fulfill({
status: 200,
contentType: 'application/json',
json: assetDto,
});
});
};
+55
View File
@@ -0,0 +1,55 @@
import { faker } from '@faker-js/faker';
import type { AssetOcrResponseDto } from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
export type MockOcrBox = {
text: string;
x1: number;
y1: number;
x2: number;
y2: number;
x3: number;
y3: number;
x4: number;
y4: number;
};
export const createMockOcrData = (assetId: string, boxes: MockOcrBox[]): AssetOcrResponseDto[] => {
return boxes.map((box) => ({
id: faker.string.uuid(),
assetId,
x1: box.x1,
y1: box.y1,
x2: box.x2,
y2: box.y2,
x3: box.x3,
y3: box.y3,
x4: box.x4,
y4: box.y4,
boxScore: 0.95,
textScore: 0.9,
text: box.text,
}));
};
export const setupOcrMockApiRoutes = async (
context: BrowserContext,
ocrDataByAssetId: Map<string, AssetOcrResponseDto[]>,
) => {
await context.route('**/assets/*/ocr', async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
const url = new URL(request.url());
const segments = url.pathname.split('/');
const assetIdIndex = segments.indexOf('assets') + 1;
const assetId = segments[assetIdIndex];
const ocrData = ocrDataByAssetId.get(assetId) ?? [];
return route.fulfill({
status: 200,
contentType: 'application/json',
json: ocrData,
});
});
};
@@ -10,16 +10,21 @@ import { assetViewerUtils } from '../timeline/utils';
import { setupAssetViewerFixture } from './utils'; import { setupAssetViewerFixture } from './utils';
const waitForSelectorTransition = async (page: Page) => { const waitForSelectorTransition = async (page: Page) => {
await page.waitForFunction( await expect(page.locator('#face-editor-data')).toHaveAttribute('data-face-width', /^[1-9]/, { timeout: 10_000 });
() => { await page.locator('#face-selector').evaluate(
const selector = document.querySelector('#face-selector') as HTMLElement | null; (el) =>
if (!selector) { new Promise<void>((resolve) => {
return false; requestAnimationFrame(() =>
} requestAnimationFrame(() => {
return selector.getAnimations({ subtree: false }).every((animation) => animation.playState === 'finished'); const animations = el.getAnimations();
}, if (animations.length === 0) {
undefined, resolve();
{ timeout: 1000, polling: 50 }, return;
}
void Promise.all(animations.map((a) => a.finished)).then(() => resolve());
}),
);
}),
); );
}; };
@@ -95,7 +100,7 @@ test.describe('face-editor', () => {
await page.mouse.down(); await page.mouse.down();
await page.mouse.move(centerX + deltaX, centerY + deltaY, { steps: 5 }); await page.mouse.move(centerX + deltaX, centerY + deltaY, { steps: 5 });
await page.mouse.up(); await page.mouse.up();
await page.waitForTimeout(300); await waitForSelectorTransition(page);
}; };
test('Face editor opens with person list', async ({ page }) => { test('Face editor opens with person list', async ({ page }) => {
@@ -149,7 +154,7 @@ test.describe('face-editor', () => {
await expect(page.getByRole('dialog')).toBeVisible(); await expect(page.getByRole('dialog')).toBeVisible();
}); });
test('Confirming tag calls createFace API and closes editor', async ({ page }) => { test('Confirming tag calls createFace API with valid coordinates and closes editor', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng); const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset); await openFaceEditor(page, asset);
@@ -163,8 +168,15 @@ test.describe('face-editor', () => {
await expect(page.locator('#face-editor')).toBeHidden(); await expect(page.locator('#face-editor')).toBeHidden();
expect(faceCreateCapture.requests).toHaveLength(1); expect(faceCreateCapture.requests).toHaveLength(1);
expect(faceCreateCapture.requests[0].assetId).toBe(asset.id); const request = faceCreateCapture.requests[0];
expect(faceCreateCapture.requests[0].personId).toBe(personToTag.id); expect(request.assetId).toBe(asset.id);
expect(request.personId).toBe(personToTag.id);
expect(request.x).toBeGreaterThanOrEqual(0);
expect(request.y).toBeGreaterThanOrEqual(0);
expect(request.width).toBeGreaterThan(0);
expect(request.height).toBeGreaterThan(0);
expect(request.x + request.width).toBeLessThanOrEqual(request.imageWidth);
expect(request.y + request.height).toBeLessThanOrEqual(request.imageHeight);
}); });
test('Cancel button closes face editor', async ({ page }) => { test('Cancel button closes face editor', async ({ page }) => {
@@ -282,4 +294,39 @@ test.describe('face-editor', () => {
expect(afterDrag.left).toBeGreaterThan(beforeDrag.left + 50); expect(afterDrag.left).toBeGreaterThan(beforeDrag.left + 50);
expect(afterDrag.top).toBeGreaterThan(beforeDrag.top + 20); expect(afterDrag.top).toBeGreaterThan(beforeDrag.top + 20);
}); });
test('Cancel on confirmation dialog keeps face editor open', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const personToTag = mockPeople[0];
await page.locator('#face-selector').getByText(personToTag.name).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page
.getByRole('dialog')
.getByRole('button', { name: /cancel/i })
.click();
await expect(page.getByRole('dialog')).toBeHidden();
await expect(page.locator('#face-selector')).toBeVisible();
await expect(page.locator('#face-editor')).toBeVisible();
expect(faceCreateCapture.requests).toHaveLength(0);
});
test('Clicking on face rect center does not reposition it', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const beforeClick = await getFaceBoxRect(page);
const centerX = beforeClick.left + beforeClick.width / 2;
const centerY = beforeClick.top + beforeClick.height / 2;
await page.mouse.click(centerX, centerY);
await waitForSelectorTransition(page);
const afterClick = await getFaceBoxRect(page);
expect(Math.abs(afterClick.left - beforeClick.left)).toBeLessThan(3);
expect(Math.abs(afterClick.top - beforeClick.top)).toBeLessThan(3);
});
}); });
@@ -0,0 +1,340 @@
import { expect, test } from '@playwright/test';
import { toAssetResponseDto } from 'src/ui/generators/timeline';
import {
createMockAssetFaces,
createMockFaceData,
createMockPeople,
type MockFaceSpec,
setupFaceEditorMockApiRoutes,
setupFaceOverlayMockApiRoutes,
setupGetFacesMockApiRoute,
} from 'src/ui/mock-network/face-editor-network';
import { assetViewerUtils } from '../timeline/utils';
import { ensureDetailPanelVisible, setupAssetViewerFixture } from './utils';
test.describe.configure({ mode: 'parallel' });
const FACE_SPECS: MockFaceSpec[] = [
{
personId: 'person-alice',
personName: 'Alice Johnson',
faceId: 'face-alice',
boundingBoxX1: 1000,
boundingBoxY1: 500,
boundingBoxX2: 1500,
boundingBoxY2: 1200,
},
{
personId: 'person-bob',
personName: 'Bob Smith',
faceId: 'face-bob',
boundingBoxX1: 2000,
boundingBoxY1: 800,
boundingBoxX2: 2400,
boundingBoxY2: 1600,
},
];
const setupFaceMocks = async (
context: import('@playwright/test').BrowserContext,
fixture: ReturnType<typeof setupAssetViewerFixture>,
) => {
const mockPeople = createMockPeople(4);
const faceData = createMockFaceData(
FACE_SPECS,
fixture.primaryAssetDto.width ?? 3000,
fixture.primaryAssetDto.height ?? 4000,
);
const assetDtoWithFaces = toAssetResponseDto(fixture.primaryAsset, undefined, faceData);
await setupFaceOverlayMockApiRoutes(context, assetDtoWithFaces);
await setupFaceEditorMockApiRoutes(context, mockPeople, { requests: [] });
};
test.describe('face overlay bounding boxes', () => {
const fixture = setupAssetViewerFixture(901);
test.beforeEach(async ({ context }) => {
await setupFaceMocks(context, fixture);
});
test('face overlay divs render with correct aria labels', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const aliceOverlay = page.getByLabel('Person: Alice Johnson');
const bobOverlay = page.getByLabel('Person: Bob Smith');
await expect(aliceOverlay).toBeVisible();
await expect(bobOverlay).toBeVisible();
});
test('face overlay shows border on hover', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const aliceOverlay = page.getByLabel('Person: Alice Johnson');
await expect(aliceOverlay).toBeVisible();
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
await expect(activeBorder).toHaveCount(0);
await aliceOverlay.hover();
await expect(activeBorder).toHaveCount(1);
});
test('face name tooltip appears on hover', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const aliceOverlay = page.getByLabel('Person: Alice Johnson');
await expect(aliceOverlay).toBeVisible();
await aliceOverlay.hover();
const nameTooltip = page.locator('[data-viewer-content]').getByText('Alice Johnson');
await expect(nameTooltip).toBeVisible();
});
test('face overlays hidden in face edit mode', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const aliceOverlay = page.getByLabel('Person: Alice Johnson');
await expect(aliceOverlay).toBeVisible();
await ensureDetailPanelVisible(page);
await page.getByLabel('Tag people').click();
await page.locator('#face-selector').waitFor({ state: 'visible' });
await expect(aliceOverlay).toBeHidden();
});
test('face overlay hover works after exiting face edit mode', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const aliceOverlay = page.getByLabel('Person: Alice Johnson');
await expect(aliceOverlay).toBeVisible();
await ensureDetailPanelVisible(page);
await page.getByLabel('Tag people').click();
await page.locator('#face-selector').waitFor({ state: 'visible' });
await expect(aliceOverlay).toBeHidden();
await page.getByRole('button', { name: /cancel/i }).click();
await expect(page.locator('#face-selector')).toBeHidden();
await expect(aliceOverlay).toBeVisible();
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
await expect(activeBorder).toHaveCount(0);
await aliceOverlay.hover();
await expect(activeBorder).toHaveCount(1);
});
});
test.describe('zoom and face editor interaction', () => {
const fixture = setupAssetViewerFixture(902);
test.beforeEach(async ({ context }) => {
await setupFaceMocks(context, fixture);
});
test('zoom is preserved when entering face edit mode', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -1);
const imgLocator = page.getByTestId('preview');
await expect(async () => {
const transform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
expect(transform).not.toBe('none');
expect(transform).not.toBe('');
}).toPass({ timeout: 2000 });
await ensureDetailPanelVisible(page);
await page.getByLabel('Tag people').click();
await page.locator('#face-selector').waitFor({ state: 'visible' });
await expect(page.locator('#face-editor')).toBeVisible();
const afterTransform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
expect(afterTransform).not.toBe('none');
});
test('modifier+drag pans zoomed image without repositioning face rect', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
for (let i = 0; i < 10; i++) {
await page.mouse.wheel(0, -3);
}
const imgLocator = page.locator('[data-viewer-content] img[data-testid="preview"]');
await expect(async () => {
const transform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
expect(transform).not.toBe('none');
}).toPass({ timeout: 2000 });
await ensureDetailPanelVisible(page);
await page.getByLabel('Tag people').click();
await page.locator('#face-selector').waitFor({ state: 'visible' });
const dataEl = page.locator('#face-editor-data');
await expect(dataEl).toHaveAttribute('data-face-width', /^[1-9]/);
const beforeLeft = Number(await dataEl.getAttribute('data-face-left'));
const beforeTop = Number(await dataEl.getAttribute('data-face-top'));
const transformBefore = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
const panModifier = await page.evaluate(() =>
/Mac|iPhone|iPad|iPod/.test(navigator.userAgent) ? 'Meta' : 'Control',
);
await page.keyboard.down(panModifier);
// Verify face editor becomes transparent to pointer events
await expect(async () => {
const pe = await dataEl.evaluate((el) => getComputedStyle(el).pointerEvents);
expect(pe).toBe('none');
}).toPass({ timeout: 2000 });
await page.mouse.move(width / 2, height / 2);
await page.mouse.down();
await page.mouse.move(width / 2 + 100, height / 2 + 50, { steps: 5 });
await page.mouse.up();
await page.keyboard.up(panModifier);
const transformAfter = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
expect(transformAfter).not.toBe(transformBefore);
// Extract translate values from matrix(a, b, c, d, tx, ty)
const parseTranslate = (matrix: string) => {
const values =
matrix
.match(/matrix\((.+)\)/)?.[1]
.split(',')
.map(Number) ?? [];
return { tx: values[4], ty: values[5] };
};
const panBefore = parseTranslate(transformBefore);
const panAfter = parseTranslate(transformAfter);
const panDeltaX = panAfter.tx - panBefore.tx;
const panDeltaY = panAfter.ty - panBefore.ty;
// Face rect screen position should have moved by the same amount as the pan
// (it follows the image), NOT been repositioned by a click
const afterLeft = Number(await dataEl.getAttribute('data-face-left'));
const afterTop = Number(await dataEl.getAttribute('data-face-top'));
const faceDeltaX = afterLeft - beforeLeft;
const faceDeltaY = afterTop - beforeTop;
expect(Math.abs(faceDeltaX - panDeltaX)).toBeLessThan(3);
expect(Math.abs(faceDeltaY - panDeltaY)).toBeLessThan(3);
});
});
test.describe('face overlay via detail panel interaction', () => {
const fixture = setupAssetViewerFixture(903);
test.beforeEach(async ({ context }) => {
await setupFaceMocks(context, fixture);
});
test('hovering person in detail panel shows face overlay border', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await ensureDetailPanelVisible(page);
const personLink = page.locator('#detail-panel a').filter({ hasText: 'Alice Johnson' });
await expect(personLink).toBeVisible();
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
await expect(activeBorder).toHaveCount(0);
await personLink.hover();
await expect(activeBorder).toHaveCount(1);
});
test('touch pointer on person in detail panel shows face overlay border', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await ensureDetailPanelVisible(page);
const personLink = page.locator('#detail-panel a').filter({ hasText: 'Alice Johnson' });
await expect(personLink).toBeVisible();
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
await expect(activeBorder).toHaveCount(0);
// Simulate a touch-type pointerover (the fix changed from onmouseover to onpointerover,
// which fires for touch pointers unlike mouseover)
await personLink.dispatchEvent('pointerover', { pointerType: 'touch' });
await expect(activeBorder).toHaveCount(1);
});
test('hovering person in detail panel works after exiting face edit mode', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await ensureDetailPanelVisible(page);
await page.getByLabel('Tag people').click();
await page.locator('#face-selector').waitFor({ state: 'visible' });
await page.getByRole('button', { name: /cancel/i }).click();
await expect(page.locator('#face-selector')).toBeHidden();
const personLink = page.locator('#detail-panel a').filter({ hasText: 'Alice Johnson' });
await expect(personLink).toBeVisible();
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
await personLink.hover();
await expect(activeBorder).toHaveCount(1);
});
});
test.describe('face overlay via edit faces side panel', () => {
const fixture = setupAssetViewerFixture(904);
test.beforeEach(async ({ context }) => {
await setupFaceMocks(context, fixture);
const assetFaces = createMockAssetFaces(
FACE_SPECS,
fixture.primaryAssetDto.width ?? 3000,
fixture.primaryAssetDto.height ?? 4000,
);
await setupGetFacesMockApiRoute(context, assetFaces);
});
test('hovering person in edit faces panel shows face overlay border', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await ensureDetailPanelVisible(page);
await page.getByLabel('Edit people').click();
const faceThumbnail = page.getByTestId('face-thumbnail').first();
await expect(faceThumbnail).toBeVisible();
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
await expect(activeBorder).toHaveCount(0);
await faceThumbnail.hover();
await expect(activeBorder).toHaveCount(1);
});
});
@@ -0,0 +1,300 @@
import type { AssetOcrResponseDto, AssetResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { toAssetResponseDto } from 'src/ui/generators/timeline';
import {
createMockStack,
createMockStackAsset,
MockStack,
setupBrokenAssetMockApiRoutes,
} from 'src/ui/mock-network/broken-asset-network';
import { createMockOcrData, setupOcrMockApiRoutes } from 'src/ui/mock-network/ocr-network';
import { assetViewerUtils } from '../timeline/utils';
import { setupAssetViewerFixture } from './utils';
test.describe.configure({ mode: 'parallel' });
const PRIMARY_OCR_BOXES = [
{ text: 'Hello World', x1: 0.1, y1: 0.1, x2: 0.4, y2: 0.1, x3: 0.4, y3: 0.15, x4: 0.1, y4: 0.15 },
{ text: 'Immich Photo', x1: 0.2, y1: 0.3, x2: 0.6, y2: 0.3, x3: 0.6, y3: 0.36, x4: 0.2, y4: 0.36 },
];
const SECONDARY_OCR_BOXES = [
{ text: 'Second Asset Text', x1: 0.15, y1: 0.2, x2: 0.55, y2: 0.2, x3: 0.55, y3: 0.26, x4: 0.15, y4: 0.26 },
];
test.describe('OCR bounding boxes', () => {
const fixture = setupAssetViewerFixture(920);
test.beforeEach(async ({ context }) => {
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
]);
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
});
test('OCR bounding boxes appear when clicking OCR button', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const ocrButton = page.getByLabel('Text recognition');
await expect(ocrButton).toBeVisible();
await ocrButton.click();
const ocrBoxes = page.locator('[data-viewer-content] [data-testid="ocr-box"]');
await expect(ocrBoxes).toHaveCount(2);
await expect(ocrBoxes.nth(0)).toContainText('Hello World');
await expect(ocrBoxes.nth(1)).toContainText('Immich Photo');
});
test('OCR bounding boxes toggle off on second click', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const ocrButton = page.getByLabel('Text recognition');
await ocrButton.click();
await expect(page.locator('[data-viewer-content] [data-testid="ocr-box"]').first()).toBeVisible();
await ocrButton.click();
await expect(page.locator('[data-viewer-content] [data-testid="ocr-box"]')).toHaveCount(0);
});
});
test.describe('OCR with stacked assets', () => {
const fixture = setupAssetViewerFixture(921);
let mockStack: MockStack;
let primaryAssetDto: AssetResponseDto;
let secondAssetDto: AssetResponseDto;
test.beforeAll(async () => {
primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
secondAssetDto = createMockStackAsset(fixture.adminUserId);
secondAssetDto.originalFileName = 'second-ocr-asset.jpg';
mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set());
});
test.beforeEach(async ({ context }) => {
await setupBrokenAssetMockApiRoutes(context, mockStack);
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
[secondAssetDto.id, createMockOcrData(secondAssetDto.id, SECONDARY_OCR_BOXES)],
]);
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
});
test('different OCR boxes shown for different stacked assets', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const ocrButton = page.getByLabel('Text recognition');
await expect(ocrButton).toBeVisible();
await ocrButton.click();
const ocrBoxes = page.locator('[data-viewer-content] [data-testid="ocr-box"]');
await expect(ocrBoxes).toHaveCount(2);
await expect(ocrBoxes.nth(0)).toContainText('Hello World');
const stackThumbnails = page.locator('#stack-slideshow [data-asset]');
await expect(stackThumbnails).toHaveCount(2);
await stackThumbnails.nth(1).click();
// refreshOcr() clears showOverlay when switching assets, so re-enable it
await expect(ocrBoxes).toHaveCount(0);
await expect(ocrButton).toBeVisible();
await ocrButton.click();
await expect(ocrBoxes).toHaveCount(1);
await expect(ocrBoxes.first()).toContainText('Second Asset Text');
});
});
test.describe('OCR boxes and zoom', () => {
const fixture = setupAssetViewerFixture(922);
test.beforeEach(async ({ context }) => {
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
]);
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
});
test('OCR boxes scale with zoom', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const ocrButton = page.getByLabel('Text recognition');
await expect(ocrButton).toBeVisible();
await ocrButton.click();
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
await expect(ocrBox).toBeVisible();
const initialBox = await ocrBox.boundingBox();
expect(initialBox).toBeTruthy();
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -3);
await expect(async () => {
const zoomedBox = await ocrBox.boundingBox();
expect(zoomedBox).toBeTruthy();
expect(zoomedBox!.width).toBeGreaterThan(initialBox!.width);
expect(zoomedBox!.height).toBeGreaterThan(initialBox!.height);
}).toPass({ timeout: 2000 });
});
});
test.describe('OCR text interaction', () => {
const fixture = setupAssetViewerFixture(923);
test.beforeEach(async ({ context }) => {
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
]);
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
});
test('OCR text box has data-overlay-interactive attribute', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await page.getByLabel('Text recognition').click();
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
await expect(ocrBox).toBeVisible();
await expect(ocrBox).toHaveAttribute('data-overlay-interactive');
});
test('OCR text box receives focus on click', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await page.getByLabel('Text recognition').click();
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
await expect(ocrBox).toBeVisible();
await ocrBox.click();
await expect(ocrBox).toBeFocused();
});
test('dragging on OCR text box does not trigger image pan', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await page.getByLabel('Text recognition').click();
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
await expect(ocrBox).toBeVisible();
const imgLocator = page.locator('[data-viewer-content] img[draggable="false"]');
const initialTransform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
const box = await ocrBox.boundingBox();
expect(box).toBeTruthy();
const centerX = box!.x + box!.width / 2;
const centerY = box!.y + box!.height / 2;
await page.mouse.move(centerX, centerY);
await page.mouse.down();
await page.mouse.move(centerX + 50, centerY + 30, { steps: 5 });
await page.mouse.up();
const afterTransform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
expect(afterTransform).toBe(initialTransform);
});
test('split touch gesture across zoom container does not trigger zoom', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await page.getByLabel('Text recognition').click();
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
await expect(ocrBox).toBeVisible();
const imgLocator = page.locator('[data-viewer-content] img[draggable="false"]');
const initialTransform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
const viewerContent = page.locator('[data-viewer-content]');
const viewerBox = await viewerContent.boundingBox();
expect(viewerBox).toBeTruthy();
// Dispatch a synthetic split gesture: one touch inside the viewer, one outside
await page.evaluate(
({ viewerCenterX, viewerCenterY, outsideY }) => {
const viewer = document.querySelector('[data-viewer-content]');
if (!viewer) {
return;
}
const createTouch = (id: number, x: number, y: number) => {
return new Touch({
identifier: id,
target: viewer,
clientX: x,
clientY: y,
});
};
const insideTouch = createTouch(0, viewerCenterX, viewerCenterY);
const outsideTouch = createTouch(1, viewerCenterX, outsideY);
const touchStartEvent = new TouchEvent('touchstart', {
touches: [insideTouch, outsideTouch],
targetTouches: [insideTouch],
changedTouches: [insideTouch, outsideTouch],
bubbles: true,
cancelable: true,
});
const touchMoveEvent = new TouchEvent('touchmove', {
touches: [createTouch(0, viewerCenterX, viewerCenterY - 30), createTouch(1, viewerCenterX, outsideY + 30)],
targetTouches: [createTouch(0, viewerCenterX, viewerCenterY - 30)],
changedTouches: [
createTouch(0, viewerCenterX, viewerCenterY - 30),
createTouch(1, viewerCenterX, outsideY + 30),
],
bubbles: true,
cancelable: true,
});
const touchEndEvent = new TouchEvent('touchend', {
touches: [],
targetTouches: [],
changedTouches: [insideTouch, outsideTouch],
bubbles: true,
cancelable: true,
});
viewer.dispatchEvent(touchStartEvent);
viewer.dispatchEvent(touchMoveEvent);
viewer.dispatchEvent(touchEndEvent);
},
{
viewerCenterX: viewerBox!.x + viewerBox!.width / 2,
viewerCenterY: viewerBox!.y + viewerBox!.height / 2,
outsideY: 10, // near the top of the page, outside the viewer
},
);
const afterTransform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
expect(afterTransform).toBe(initialTransform);
});
});
@@ -0,0 +1,127 @@
import { expect, type Page, test } from '@playwright/test';
import { assetViewerUtils } from '../timeline/utils';
import { setupAssetViewerFixture } from './utils';
test.describe.configure({ mode: 'parallel' });
const zoomIn = async (page: Page) => {
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -1);
await page.waitForTimeout(300);
};
const getImageTransform = (page: Page) => {
return page.getByTestId('preview').evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
};
test.describe('zoom minimap', () => {
const fixture = setupAssetViewerFixture(950);
test('minimap is not visible at 1x zoom', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await expect(page.getByTestId('zoom-minimap')).toHaveCount(0);
});
test('minimap appears when zoomed in', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await zoomIn(page);
await expect(page.getByTestId('zoom-minimap')).toBeVisible();
});
test('minimap contains thumbnail image', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await zoomIn(page);
const canvas = page.getByTestId('zoom-minimap-canvas');
await expect(canvas).toBeVisible();
const img = canvas.locator('img');
await expect(img).toBeVisible();
await expect(img).toHaveAttribute('src', /thumbnail/);
});
test('viewport rect is visible when zoomed', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await zoomIn(page);
const viewport = page.getByTestId('zoom-minimap-viewport');
await expect(viewport).toBeVisible();
const box = await viewport.boundingBox();
expect(box).toBeTruthy();
expect(box!.width).toBeGreaterThan(0);
expect(box!.height).toBeGreaterThan(0);
});
test('clicking minimap pans the image', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await zoomIn(page);
const transformBefore = await getImageTransform(page);
const canvas = page.getByTestId('zoom-minimap-canvas');
const canvasBox = await canvas.boundingBox();
expect(canvasBox).toBeTruthy();
// Click near the top-left corner of the minimap
await page.mouse.click(canvasBox!.x + 20, canvasBox!.y + 20);
await page.waitForTimeout(100);
const transformAfter = await getImageTransform(page);
expect(transformAfter).not.toBe(transformBefore);
});
test('zoom slider adjusts zoom level', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await zoomIn(page);
const slider = page.getByTestId('zoom-minimap-slider');
await expect(slider).toBeVisible();
const sliderBox = await slider.boundingBox();
expect(sliderBox).toBeTruthy();
const fillBefore = await page.getByTestId('zoom-minimap-slider-fill').evaluate((element) => {
return element.style.width;
});
// Click near the right end of the slider to increase zoom
await page.mouse.click(sliderBox!.x + sliderBox!.width * 0.8, sliderBox!.y + sliderBox!.height / 2);
await page.waitForTimeout(100);
const fillAfter = await page.getByTestId('zoom-minimap-slider-fill').evaluate((element) => {
return element.style.width;
});
expect(fillAfter).not.toBe(fillBefore);
});
test('minimap auto-hides after inactivity', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await zoomIn(page);
await expect(page.getByTestId('zoom-minimap')).toBeVisible();
// Wait for the hide delay (1500ms) plus fade duration
await page.waitForTimeout(2000);
await expect(page.getByTestId('zoom-minimap')).toHaveCount(0);
});
});
+1
View File
@@ -1275,6 +1275,7 @@
"hide_schema": "Hide schema", "hide_schema": "Hide schema",
"hide_text_recognition": "Hide text recognition", "hide_text_recognition": "Hide text recognition",
"hide_unnamed_people": "Hide unnamed people", "hide_unnamed_people": "Hide unnamed people",
"hold_key_to_pan": "Hold {key} to pan",
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
"home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping",
"home_page_add_to_album_success": "Added {added} assets to album {album}.", "home_page_add_to_album_success": "Added {added} assets to album {album}.",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-i18n", "name": "immich-i18n",
"version": "2.6.2", "version": "2.6.1",
"private": true, "private": true,
"scripts": { "scripts": {
"format": "prettier --cache --check .", "format": "prettier --cache --check .",
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "immich-ml" name = "immich-ml"
version = "2.6.2" version = "2.6.1"
description = "" description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }] authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.11,<4.0" requires-python = ">=3.11,<4.0"
+1 -1
View File
@@ -898,7 +898,7 @@ wheels = [
[[package]] [[package]]
name = "immich-ml" name = "immich-ml"
version = "2.6.2" version = "2.6.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiocache" }, { name = "aiocache" },
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 3040, "android.injected.version.code" => 3039,
"android.injected.version.name" => "2.6.2", "android.injected.version.name" => "2.6.1",
} }
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
@@ -150,6 +150,7 @@ class URLSessionManager: NSObject {
config.httpCookieStorage = cookieStorage config.httpCookieStorage = cookieStorage
config.httpMaximumConnectionsPerHost = 64 config.httpMaximumConnectionsPerHost = 64
config.timeoutIntervalForRequest = 60 config.timeoutIntervalForRequest = 60
config.timeoutIntervalForResource = 300
var headers = UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] ?? [:] var headers = UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] ?? [:]
headers["User-Agent"] = headers["User-Agent"] ?? userAgent headers["User-Agent"] = headers["User-Agent"] ?? userAgent
+1 -1
View File
@@ -80,7 +80,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2.6.2</string> <string>2.6.1</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@@ -79,7 +79,6 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
final person = people[index]; final person = people[index];
return Column( return Column(
key: ValueKey(person.id),
children: [ children: [
GestureDetector( GestureDetector(
onTap: () { onTap: () {
@@ -89,7 +88,6 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
shape: const CircleBorder(side: BorderSide.none), shape: const CircleBorder(side: BorderSide.none),
elevation: 3, elevation: 3,
child: CircleAvatar( child: CircleAvatar(
key: ValueKey('avatar-${person.id}'),
maxRadius: isTablet ? 100 / 2 : 96 / 2, maxRadius: isTablet ? 100 / 2 : 96 / 2,
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)), backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)),
), ),
@@ -69,7 +69,6 @@ class DriftSearchPage extends HookConsumerWidget {
); );
final previousFilter = useState<SearchFilter?>(null); final previousFilter = useState<SearchFilter?>(null);
final hasRequestedSearch = useState<bool>(false);
final dateInputFilter = useState<DateFilterInputModel?>(null); final dateInputFilter = useState<DateFilterInputModel?>(null);
final peopleCurrentFilterWidget = useState<Widget?>(null); final peopleCurrentFilterWidget = useState<Widget?>(null);
@@ -92,11 +91,9 @@ class DriftSearchPage extends HookConsumerWidget {
if (filter.isEmpty) { if (filter.isEmpty) {
previousFilter.value = null; previousFilter.value = null;
hasRequestedSearch.value = false;
return; return;
} }
hasRequestedSearch.value = true;
unawaited(ref.read(paginatedSearchProvider.notifier).search(filter)); unawaited(ref.read(paginatedSearchProvider.notifier).search(filter));
previousFilter.value = filter; previousFilter.value = filter;
} }
@@ -110,8 +107,6 @@ class DriftSearchPage extends HookConsumerWidget {
searchPreFilter() { searchPreFilter() {
if (preFilter != null) { if (preFilter != null) {
Future.delayed(Duration.zero, () { Future.delayed(Duration.zero, () {
filter.value = preFilter;
textSearchController.clear();
searchFilter(preFilter); searchFilter(preFilter);
if (preFilter.location.city != null) { if (preFilter.location.city != null) {
@@ -724,7 +719,7 @@ class DriftSearchPage extends HookConsumerWidget {
), ),
), ),
), ),
if (!hasRequestedSearch.value) if (filter.value.isEmpty)
const _SearchSuggestions() const _SearchSuggestions()
else else
_SearchResultGrid(onScrollEnd: loadMoreSearchResults), _SearchResultGrid(onScrollEnd: loadMoreSearchResults),
@@ -24,22 +24,20 @@ class SimilarPhotosActionButton extends ConsumerWidget {
} }
ref.invalidate(assetViewerProvider); ref.invalidate(assetViewerProvider);
ref.invalidate(paginatedSearchProvider); ref
.read(searchPreFilterProvider.notifier)
ref.read(searchPreFilterProvider.notifier) .setFilter(
..clear() SearchFilter(
..setFilter( assetId: assetId,
SearchFilter( people: {},
assetId: assetId, location: SearchLocationFilter(),
people: {}, camera: SearchCameraFilter(),
location: SearchLocationFilter(), date: SearchDateFilter(),
camera: SearchCameraFilter(), display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
date: SearchDateFilter(), rating: SearchRatingFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), mediaType: AssetType.image,
rating: SearchRatingFilter(), ),
mediaType: AssetType.image, );
),
);
unawaited(context.navigateTo(const DriftSearchRoute())); unawaited(context.navigateTo(const DriftSearchRoute()));
} }
@@ -39,16 +39,6 @@ class _RatingBarState extends State<RatingBar> {
_currentRating = widget.initialRating; _currentRating = widget.initialRating;
} }
@override
void didUpdateWidget(covariant RatingBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialRating != widget.initialRating && _currentRating != widget.initialRating) {
setState(() {
_currentRating = widget.initialRating;
});
}
}
void _updateRating(Offset localPosition, bool isRTL, {bool isTap = false}) { void _updateRating(Offset localPosition, bool isRTL, {bool isTap = false}) {
final totalWidth = widget.itemCount * widget.itemSize + (widget.itemCount - 1) * widget.starPadding; final totalWidth = widget.itemCount * widget.itemSize + (widget.itemCount - 1) * widget.starPadding;
double dx = localPosition.dx; double dx = localPosition.dx;
-3
View File
@@ -67,9 +67,6 @@ class AuthService {
bool isValid = false; bool isValid = false;
try { try {
final urls = ApiService.getServerUrls();
urls.add(url);
await NetworkRepository.setHeaders(ApiService.getRequestHeaders(), urls);
final uri = Uri.parse('$url/users/me'); final uri = Uri.parse('$url/users/me');
final response = await NetworkRepository.client.get(uri); final response = await NetworkRepository.client.get(uri);
if (response.statusCode == 200) { if (response.statusCode == 200) {
+2 -1
View File
@@ -143,7 +143,8 @@ enum ActionButtonType {
!context.isInLockedView && // !context.isInLockedView && //
context.currentAlbum != null, context.currentAlbum != null,
ActionButtonType.setAlbumCover => ActionButtonType.setAlbumCover =>
!context.isInLockedView && // context.isOwner && //
!context.isInLockedView && //
context.currentAlbum != null && // context.currentAlbum != null && //
context.selectedCount == 1, context.selectedCount == 1,
ActionButtonType.unstack => ActionButtonType.unstack =>
@@ -16,15 +16,9 @@ class SearchDropdown<T> extends StatelessWidget {
final Widget? label; final Widget? label;
final Widget? leadingIcon; final Widget? leadingIcon;
static const WidgetStatePropertyAll<EdgeInsetsGeometry> _optionPadding = WidgetStatePropertyAll<EdgeInsetsGeometry>(
EdgeInsetsDirectional.fromSTEB(16, 0, 16, 0),
);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context); final menuStyle = const MenuStyle(
final maxMenuHeight = mediaQuery.size.height * 0.5 - mediaQuery.viewPadding.bottom;
const menuStyle = MenuStyle(
shape: WidgetStatePropertyAll<OutlinedBorder>( shape: WidgetStatePropertyAll<OutlinedBorder>(
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15))), RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15))),
), ),
@@ -32,26 +26,11 @@ class SearchDropdown<T> extends StatelessWidget {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final styledEntries = dropdownMenuEntries
.map(
(entry) => DropdownMenuEntry<T>(
value: entry.value,
label: entry.label,
labelWidget: entry.labelWidget,
enabled: entry.enabled,
leadingIcon: entry.leadingIcon,
trailingIcon: entry.trailingIcon,
style: (entry.style ?? const ButtonStyle()).copyWith(padding: _optionPadding),
),
)
.toList(growable: false);
return DropdownMenu( return DropdownMenu(
controller: controller, controller: controller,
leadingIcon: leadingIcon, leadingIcon: leadingIcon,
width: constraints.maxWidth, width: constraints.maxWidth,
menuHeight: maxMenuHeight, dropdownMenuEntries: dropdownMenuEntries,
dropdownMenuEntries: styledEntries,
label: label, label: label,
menuStyle: menuStyle, menuStyle: menuStyle,
trailingIcon: const Icon(Icons.arrow_drop_down_rounded), trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
+1 -1
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 2.6.2 - API version: 2.6.1
- Generator version: 7.8.0 - Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen - Build package: org.openapitools.codegen.languages.DartClientCodegen
+3 -12
View File
@@ -1004,13 +1004,10 @@ class AssetsApi {
/// ///
/// * [String] id (required): /// * [String] id (required):
/// ///
/// * [bool] edited:
/// Return edited asset if available
///
/// * [String] key: /// * [String] key:
/// ///
/// * [String] slug: /// * [String] slug:
Future<Response> playAssetVideoWithHttpInfo(String id, { bool? edited, String? key, String? slug, }) async { Future<Response> playAssetVideoWithHttpInfo(String id, { String? key, String? slug, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/video/playback' final apiPath = r'/assets/{id}/video/playback'
.replaceAll('{id}', id); .replaceAll('{id}', id);
@@ -1022,9 +1019,6 @@ class AssetsApi {
final headerParams = <String, String>{}; final headerParams = <String, String>{};
final formParams = <String, String>{}; final formParams = <String, String>{};
if (edited != null) {
queryParams.addAll(_queryParams('', 'edited', edited));
}
if (key != null) { if (key != null) {
queryParams.addAll(_queryParams('', 'key', key)); queryParams.addAll(_queryParams('', 'key', key));
} }
@@ -1054,14 +1048,11 @@ class AssetsApi {
/// ///
/// * [String] id (required): /// * [String] id (required):
/// ///
/// * [bool] edited:
/// Return edited asset if available
///
/// * [String] key: /// * [String] key:
/// ///
/// * [String] slug: /// * [String] slug:
Future<MultipartFile?> playAssetVideo(String id, { bool? edited, String? key, String? slug, }) async { Future<MultipartFile?> playAssetVideo(String id, { String? key, String? slug, }) async {
final response = await playAssetVideoWithHttpInfo(id, edited: edited, key: key, slug: slug, ); final response = await playAssetVideoWithHttpInfo(id, key: key, slug: slug, );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
+3 -3
View File
@@ -29,6 +29,7 @@ class JobName {
static const assetDetectFaces = JobName._(r'AssetDetectFaces'); static const assetDetectFaces = JobName._(r'AssetDetectFaces');
static const assetDetectDuplicatesQueueAll = JobName._(r'AssetDetectDuplicatesQueueAll'); static const assetDetectDuplicatesQueueAll = JobName._(r'AssetDetectDuplicatesQueueAll');
static const assetDetectDuplicates = JobName._(r'AssetDetectDuplicates'); static const assetDetectDuplicates = JobName._(r'AssetDetectDuplicates');
static const assetEditThumbnailGeneration = JobName._(r'AssetEditThumbnailGeneration');
static const assetEncodeVideoQueueAll = JobName._(r'AssetEncodeVideoQueueAll'); static const assetEncodeVideoQueueAll = JobName._(r'AssetEncodeVideoQueueAll');
static const assetEncodeVideo = JobName._(r'AssetEncodeVideo'); static const assetEncodeVideo = JobName._(r'AssetEncodeVideo');
static const assetEmptyTrash = JobName._(r'AssetEmptyTrash'); static const assetEmptyTrash = JobName._(r'AssetEmptyTrash');
@@ -37,7 +38,6 @@ class JobName {
static const assetFileMigration = JobName._(r'AssetFileMigration'); static const assetFileMigration = JobName._(r'AssetFileMigration');
static const assetGenerateThumbnailsQueueAll = JobName._(r'AssetGenerateThumbnailsQueueAll'); static const assetGenerateThumbnailsQueueAll = JobName._(r'AssetGenerateThumbnailsQueueAll');
static const assetGenerateThumbnails = JobName._(r'AssetGenerateThumbnails'); static const assetGenerateThumbnails = JobName._(r'AssetGenerateThumbnails');
static const assetProcessEdit = JobName._(r'AssetProcessEdit');
static const auditLogCleanup = JobName._(r'AuditLogCleanup'); static const auditLogCleanup = JobName._(r'AuditLogCleanup');
static const auditTableCleanup = JobName._(r'AuditTableCleanup'); static const auditTableCleanup = JobName._(r'AuditTableCleanup');
static const databaseBackup = JobName._(r'DatabaseBackup'); static const databaseBackup = JobName._(r'DatabaseBackup');
@@ -88,6 +88,7 @@ class JobName {
assetDetectFaces, assetDetectFaces,
assetDetectDuplicatesQueueAll, assetDetectDuplicatesQueueAll,
assetDetectDuplicates, assetDetectDuplicates,
assetEditThumbnailGeneration,
assetEncodeVideoQueueAll, assetEncodeVideoQueueAll,
assetEncodeVideo, assetEncodeVideo,
assetEmptyTrash, assetEmptyTrash,
@@ -96,7 +97,6 @@ class JobName {
assetFileMigration, assetFileMigration,
assetGenerateThumbnailsQueueAll, assetGenerateThumbnailsQueueAll,
assetGenerateThumbnails, assetGenerateThumbnails,
assetProcessEdit,
auditLogCleanup, auditLogCleanup,
auditTableCleanup, auditTableCleanup,
databaseBackup, databaseBackup,
@@ -182,6 +182,7 @@ class JobNameTypeTransformer {
case r'AssetDetectFaces': return JobName.assetDetectFaces; case r'AssetDetectFaces': return JobName.assetDetectFaces;
case r'AssetDetectDuplicatesQueueAll': return JobName.assetDetectDuplicatesQueueAll; case r'AssetDetectDuplicatesQueueAll': return JobName.assetDetectDuplicatesQueueAll;
case r'AssetDetectDuplicates': return JobName.assetDetectDuplicates; case r'AssetDetectDuplicates': return JobName.assetDetectDuplicates;
case r'AssetEditThumbnailGeneration': return JobName.assetEditThumbnailGeneration;
case r'AssetEncodeVideoQueueAll': return JobName.assetEncodeVideoQueueAll; case r'AssetEncodeVideoQueueAll': return JobName.assetEncodeVideoQueueAll;
case r'AssetEncodeVideo': return JobName.assetEncodeVideo; case r'AssetEncodeVideo': return JobName.assetEncodeVideo;
case r'AssetEmptyTrash': return JobName.assetEmptyTrash; case r'AssetEmptyTrash': return JobName.assetEmptyTrash;
@@ -190,7 +191,6 @@ class JobNameTypeTransformer {
case r'AssetFileMigration': return JobName.assetFileMigration; case r'AssetFileMigration': return JobName.assetFileMigration;
case r'AssetGenerateThumbnailsQueueAll': return JobName.assetGenerateThumbnailsQueueAll; case r'AssetGenerateThumbnailsQueueAll': return JobName.assetGenerateThumbnailsQueueAll;
case r'AssetGenerateThumbnails': return JobName.assetGenerateThumbnails; case r'AssetGenerateThumbnails': return JobName.assetGenerateThumbnails;
case r'AssetProcessEdit': return JobName.assetProcessEdit;
case r'AuditLogCleanup': return JobName.auditLogCleanup; case r'AuditLogCleanup': return JobName.auditLogCleanup;
case r'AuditTableCleanup': return JobName.auditTableCleanup; case r'AuditTableCleanup': return JobName.auditTableCleanup;
case r'DatabaseBackup': return JobName.databaseBackup; case r'DatabaseBackup': return JobName.databaseBackup;
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 2.6.2+3040 version: 2.6.1+3039
environment: environment:
sdk: '>=3.8.0 <4.0.0' sdk: '>=3.8.0 <4.0.0'
@@ -727,7 +727,7 @@ void main() {
expect(ActionButtonType.setAlbumCover.shouldShow(context), isTrue); expect(ActionButtonType.setAlbumCover.shouldShow(context), isTrue);
}); });
test('should show when not owner', () { test('should not show when not owner', () {
final album = createRemoteAlbum(); final album = createRemoteAlbum();
final context = ActionButtonContext( final context = ActionButtonContext(
asset: mergedAsset, asset: mergedAsset,
@@ -742,7 +742,7 @@ void main() {
selectedCount: 1, selectedCount: 1,
); );
expect(ActionButtonType.setAlbumCover.shouldShow(context), isTrue); expect(ActionButtonType.setAlbumCover.shouldShow(context), isFalse);
}); });
test('should not show when in locked view', () { test('should not show when in locked view', () {
+2 -12
View File
@@ -4402,16 +4402,6 @@
"description": "Streams the video file for the specified asset. This endpoint also supports byte range requests.", "description": "Streams the video file for the specified asset. This endpoint also supports byte range requests.",
"operationId": "playAssetVideo", "operationId": "playAssetVideo",
"parameters": [ "parameters": [
{
"name": "edited",
"required": false,
"in": "query",
"description": "Return edited asset if available",
"schema": {
"default": false,
"type": "boolean"
}
},
{ {
"name": "id", "name": "id",
"required": true, "required": true,
@@ -15176,7 +15166,7 @@
"info": { "info": {
"title": "Immich", "title": "Immich",
"description": "Immich API", "description": "Immich API",
"version": "2.6.2", "version": "2.6.1",
"contact": {} "contact": {}
}, },
"tags": [ "tags": [
@@ -18154,6 +18144,7 @@
"AssetDetectFaces", "AssetDetectFaces",
"AssetDetectDuplicatesQueueAll", "AssetDetectDuplicatesQueueAll",
"AssetDetectDuplicates", "AssetDetectDuplicates",
"AssetEditThumbnailGeneration",
"AssetEncodeVideoQueueAll", "AssetEncodeVideoQueueAll",
"AssetEncodeVideo", "AssetEncodeVideo",
"AssetEmptyTrash", "AssetEmptyTrash",
@@ -18162,7 +18153,6 @@
"AssetFileMigration", "AssetFileMigration",
"AssetGenerateThumbnailsQueueAll", "AssetGenerateThumbnailsQueueAll",
"AssetGenerateThumbnails", "AssetGenerateThumbnails",
"AssetProcessEdit",
"AuditLogCleanup", "AuditLogCleanup",
"AuditTableCleanup", "AuditTableCleanup",
"DatabaseBackup", "DatabaseBackup",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "2.6.2", "version": "2.6.1",
"description": "Auto-generated TypeScript SDK for the Immich API", "description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",
+3 -5
View File
@@ -1,6 +1,6 @@
/** /**
* Immich * Immich
* 2.6.2 * 2.6.1
* DO NOT MODIFY - This file has been generated using oazapfts. * DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts * See https://www.npmjs.com/package/oazapfts
*/ */
@@ -4316,8 +4316,7 @@ export function viewAsset({ edited, id, key, size, slug }: {
/** /**
* Play asset video * Play asset video
*/ */
export function playAssetVideo({ edited, id, key, slug }: { export function playAssetVideo({ id, key, slug }: {
edited?: boolean;
id: string; id: string;
key?: string; key?: string;
slug?: string; slug?: string;
@@ -4326,7 +4325,6 @@ export function playAssetVideo({ edited, id, key, slug }: {
status: 200; status: 200;
data: Blob; data: Blob;
}>(`/assets/${encodeURIComponent(id)}/video/playback${QS.query(QS.explode({ }>(`/assets/${encodeURIComponent(id)}/video/playback${QS.query(QS.explode({
edited,
key, key,
slug slug
}))}`, { }))}`, {
@@ -7166,6 +7164,7 @@ export enum JobName {
AssetDetectFaces = "AssetDetectFaces", AssetDetectFaces = "AssetDetectFaces",
AssetDetectDuplicatesQueueAll = "AssetDetectDuplicatesQueueAll", AssetDetectDuplicatesQueueAll = "AssetDetectDuplicatesQueueAll",
AssetDetectDuplicates = "AssetDetectDuplicates", AssetDetectDuplicates = "AssetDetectDuplicates",
AssetEditThumbnailGeneration = "AssetEditThumbnailGeneration",
AssetEncodeVideoQueueAll = "AssetEncodeVideoQueueAll", AssetEncodeVideoQueueAll = "AssetEncodeVideoQueueAll",
AssetEncodeVideo = "AssetEncodeVideo", AssetEncodeVideo = "AssetEncodeVideo",
AssetEmptyTrash = "AssetEmptyTrash", AssetEmptyTrash = "AssetEmptyTrash",
@@ -7174,7 +7173,6 @@ export enum JobName {
AssetFileMigration = "AssetFileMigration", AssetFileMigration = "AssetFileMigration",
AssetGenerateThumbnailsQueueAll = "AssetGenerateThumbnailsQueueAll", AssetGenerateThumbnailsQueueAll = "AssetGenerateThumbnailsQueueAll",
AssetGenerateThumbnails = "AssetGenerateThumbnails", AssetGenerateThumbnails = "AssetGenerateThumbnails",
AssetProcessEdit = "AssetProcessEdit",
AuditLogCleanup = "AuditLogCleanup", AuditLogCleanup = "AuditLogCleanup",
AuditTableCleanup = "AuditTableCleanup", AuditTableCleanup = "AuditTableCleanup",
DatabaseBackup = "DatabaseBackup", DatabaseBackup = "DatabaseBackup",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-monorepo", "name": "immich-monorepo",
"version": "2.6.2", "version": "2.6.1",
"description": "Monorepo for Immich", "description": "Monorepo for Immich",
"private": true, "private": true,
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017", "packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
+250 -527
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -52,7 +52,7 @@ FROM builder AS plugins
ARG TARGETPLATFORM ARG TARGETPLATFORM
COPY --from=ghcr.io/jdx/mise:2026.3.12@sha256:0210678cbf58413806531a27adb2c7daf1c37238e56e8f7ea381d73521571775 /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 WORKDIR /usr/src/app
COPY ./plugins/mise.toml ./plugins/ COPY ./plugins/mise.toml ./plugins/
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich", "name": "immich",
"version": "2.6.2", "version": "2.6.1",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -30,7 +30,6 @@ import {
AssetMediaOptionsDto, AssetMediaOptionsDto,
AssetMediaReplaceDto, AssetMediaReplaceDto,
AssetMediaSize, AssetMediaSize,
AssetThumbnailOptionsDto,
CheckExistingAssetsDto, CheckExistingAssetsDto,
UploadFieldName, UploadFieldName,
} from 'src/dtos/asset-media.dto'; } from 'src/dtos/asset-media.dto';
@@ -155,7 +154,7 @@ export class AssetMediaController {
async viewAsset( async viewAsset(
@Auth() auth: AuthDto, @Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto, @Param() { id }: UUIDParamDto,
@Query() dto: AssetThumbnailOptionsDto, @Query() dto: AssetMediaOptionsDto,
@Req() req: Request, @Req() req: Request,
@Res() res: Response, @Res() res: Response,
@Next() next: NextFunction, @Next() next: NextFunction,
@@ -198,10 +197,9 @@ export class AssetMediaController {
@Auth() auth: AuthDto, @Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto, @Param() { id }: UUIDParamDto,
@Res() res: Response, @Res() res: Response,
@Query() dto: AssetMediaOptionsDto,
@Next() next: NextFunction, @Next() next: NextFunction,
) { ) {
await sendFile(res, next, () => this.service.playbackVideo(auth, id, dto), this.logger); await sendFile(res, next, () => this.service.playbackVideo(auth, id), this.logger);
} }
@Post('exist') @Post('exist')
+2 -2
View File
@@ -120,8 +120,8 @@ export class StorageCore {
); );
} }
static getEncodedVideoPath(asset: ThumbnailPathEntity, isEdited: boolean = false) { static getEncodedVideoPath(asset: ThumbnailPathEntity) {
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${asset.id}${isEdited ? '_edited' : ''}.mp4`); return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${asset.id}.mp4`);
} }
static getAndroidMotionPath(asset: ThumbnailPathEntity, uuid: string) { static getAndroidMotionPath(asset: ThumbnailPathEntity, uuid: string) {
+2 -1
View File
@@ -346,7 +346,8 @@ export const columns = {
'asset.width', 'asset.width',
'asset.height', 'asset.height',
], ],
assetFiles: [ assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'],
assetFilesForThumbnail: [
'asset_file.id', 'asset_file.id',
'asset_file.path', 'asset_file.path',
'asset_file.type', 'asset_file.type',
+3 -5
View File
@@ -18,13 +18,11 @@ export enum AssetMediaSize {
} }
export class AssetMediaOptionsDto { export class AssetMediaOptionsDto {
@ValidateBoolean({ optional: true, description: 'Return edited asset if available', default: false })
edited?: boolean;
}
export class AssetThumbnailOptionsDto extends AssetMediaOptionsDto {
@ValidateEnum({ enum: AssetMediaSize, name: 'AssetMediaSize', description: 'Asset media size', optional: true }) @ValidateEnum({ enum: AssetMediaSize, name: 'AssetMediaSize', description: 'Asset media size', optional: true })
size?: AssetMediaSize; size?: AssetMediaSize;
@ValidateBoolean({ optional: true, description: 'Return edited asset if available', default: false })
edited?: boolean;
} }
export enum UploadFieldName { export enum UploadFieldName {
+1 -1
View File
@@ -588,6 +588,7 @@ export enum JobName {
AssetDetectFaces = 'AssetDetectFaces', AssetDetectFaces = 'AssetDetectFaces',
AssetDetectDuplicatesQueueAll = 'AssetDetectDuplicatesQueueAll', AssetDetectDuplicatesQueueAll = 'AssetDetectDuplicatesQueueAll',
AssetDetectDuplicates = 'AssetDetectDuplicates', AssetDetectDuplicates = 'AssetDetectDuplicates',
AssetEditThumbnailGeneration = 'AssetEditThumbnailGeneration',
AssetEncodeVideoQueueAll = 'AssetEncodeVideoQueueAll', AssetEncodeVideoQueueAll = 'AssetEncodeVideoQueueAll',
AssetEncodeVideo = 'AssetEncodeVideo', AssetEncodeVideo = 'AssetEncodeVideo',
AssetEmptyTrash = 'AssetEmptyTrash', AssetEmptyTrash = 'AssetEmptyTrash',
@@ -596,7 +597,6 @@ export enum JobName {
AssetFileMigration = 'AssetFileMigration', AssetFileMigration = 'AssetFileMigration',
AssetGenerateThumbnailsQueueAll = 'AssetGenerateThumbnailsQueueAll', AssetGenerateThumbnailsQueueAll = 'AssetGenerateThumbnailsQueueAll',
AssetGenerateThumbnails = 'AssetGenerateThumbnails', AssetGenerateThumbnails = 'AssetGenerateThumbnails',
AssetProcessEdit = 'AssetProcessEdit',
AuditLogCleanup = 'AuditLogCleanup', AuditLogCleanup = 'AuditLogCleanup',
AuditTableCleanup = 'AuditTableCleanup', AuditTableCleanup = 'AuditTableCleanup',
+15 -103
View File
@@ -30,9 +30,7 @@ select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type", "asset_file"."type",
"asset_file"."isEdited", "asset_file"."isEdited"
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from from
"asset_file" "asset_file"
where where
@@ -62,9 +60,7 @@ select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type", "asset_file"."type",
"asset_file"."isEdited", "asset_file"."isEdited"
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from from
"asset_file" "asset_file"
where where
@@ -188,9 +184,7 @@ select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type", "asset_file"."type",
"asset_file"."isEdited", "asset_file"."isEdited"
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from from
"asset_file" "asset_file"
where where
@@ -251,55 +245,6 @@ from
where where
"asset"."id" = $4 "asset"."id" = $4
-- AssetJobRepository.getForAssetEditProcessing
select
"asset"."id",
"asset"."visibility",
"asset"."originalFileName",
"asset"."originalPath",
"asset"."ownerId",
"asset"."thumbhash",
"asset"."type",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type",
"asset_file"."isEdited",
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" in ($1, $2, $3, $4)
) 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"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."id" = $5
-- AssetJobRepository.getForMetadataExtraction -- AssetJobRepository.getForMetadataExtraction
select select
"asset"."id", "asset"."id",
@@ -343,9 +288,7 @@ select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type", "asset_file"."type",
"asset_file"."isEdited", "asset_file"."isEdited"
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from from
"asset_file" "asset_file"
where where
@@ -371,9 +314,7 @@ select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type", "asset_file"."type",
"asset_file"."isEdited", "asset_file"."isEdited"
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from from
"asset_file" "asset_file"
where where
@@ -430,9 +371,7 @@ select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type", "asset_file"."type",
"asset_file"."isEdited", "asset_file"."isEdited"
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from from
"asset_file" "asset_file"
where where
@@ -472,9 +411,7 @@ select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type", "asset_file"."type",
"asset_file"."isEdited", "asset_file"."isEdited"
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from from
"asset_file" "asset_file"
where where
@@ -499,12 +436,11 @@ select
where where
"asset_file"."assetId" = "asset"."id" "asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1 and "asset_file"."type" = $1
and "asset_file"."isEdited" = $2
) as "previewFile" ) as "previewFile"
from from
"asset" "asset"
where where
"asset"."id" = $3 "asset"."id" = $2
-- AssetJobRepository.getForSyncAssets -- AssetJobRepository.getForSyncAssets
select select
@@ -538,9 +474,7 @@ select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type", "asset_file"."type",
"asset_file"."isEdited", "asset_file"."isEdited"
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from from
"asset_file" "asset_file"
where where
@@ -581,8 +515,7 @@ where
-- AssetJobRepository.streamForVideoConversion -- AssetJobRepository.streamForVideoConversion
select select
"asset"."id", "asset"."id"
"asset"."isEdited"
from from
"asset" "asset"
where where
@@ -613,34 +546,17 @@ select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type", "asset_file"."type",
"asset_file"."isEdited", "asset_file"."isEdited"
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from from
"asset_file" "asset_file"
where where
"asset_file"."assetId" = "asset"."id" "asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
) as agg ) 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 from
"asset" "asset"
where where
"asset"."id" = $2 "asset"."id" = $1
and "asset"."type" = 'VIDEO' and "asset"."type" = 'VIDEO'
-- AssetJobRepository.streamForMetadataExtraction -- AssetJobRepository.streamForMetadataExtraction
@@ -682,9 +598,7 @@ select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type", "asset_file"."type",
"asset_file"."isEdited", "asset_file"."isEdited"
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from from
"asset_file" "asset_file"
where where
@@ -726,9 +640,7 @@ select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type", "asset_file"."type",
"asset_file"."isEdited", "asset_file"."isEdited"
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from from
"asset_file" "asset_file"
where where
+3 -6
View File
@@ -285,9 +285,7 @@ select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type", "asset_file"."type",
"asset_file"."isEdited", "asset_file"."isEdited"
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from from
"asset_file" "asset_file"
where where
@@ -640,13 +638,12 @@ select
where where
"asset_file"."assetId" = "asset"."id" "asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1 and "asset_file"."type" = $1
and "asset_file"."isEdited" = $2
) as "encodedVideoPath" ) as "encodedVideoPath"
from from
"asset" "asset"
where where
"asset"."id" = $3 "asset"."id" = $2
and "asset"."type" = $4 and "asset"."type" = $3
-- AssetRepository.getForOcr -- AssetRepository.getForOcr
select select
@@ -112,26 +112,6 @@ export class AssetJobRepository {
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getForGenerateThumbnailJob(id: string) { getForGenerateThumbnailJob(id: string) {
return this.db
.selectFrom('asset')
.select([
'asset.id',
'asset.visibility',
'asset.originalFileName',
'asset.originalPath',
'asset.ownerId',
'asset.thumbhash',
'asset.type',
])
.select((eb) => withFiles(eb, [AssetFileType.Thumbnail, AssetFileType.Preview, AssetFileType.FullSize]))
.select(withEdits)
.$call(withExifInner)
.where('asset.id', '=', id)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getForAssetEditProcessing(id: string) {
return this.db return this.db
.selectFrom('asset') .selectFrom('asset')
.select([ .select([
@@ -144,12 +124,13 @@ export class AssetJobRepository {
'asset.type', 'asset.type',
]) ])
.select((eb) => .select((eb) =>
withFiles(eb, [ jsonArrayFrom(
AssetFileType.Thumbnail, eb
AssetFileType.Preview, .selectFrom('asset_file')
AssetFileType.FullSize, .select(columns.assetFilesForThumbnail)
AssetFileType.EncodedVideo, .whereRef('asset_file.assetId', '=', 'asset.id')
]), .where('asset_file.type', 'in', [AssetFileType.Thumbnail, AssetFileType.Preview, AssetFileType.FullSize]),
).as('files'),
) )
.select(withEdits) .select(withEdits)
.$call(withExifInner) .$call(withExifInner)
@@ -327,7 +308,7 @@ export class AssetJobRepository {
streamForVideoConversion(force?: boolean) { streamForVideoConversion(force?: boolean) {
return this.db return this.db
.selectFrom('asset') .selectFrom('asset')
.select(['asset.id', 'asset.isEdited']) .select(['asset.id'])
.where('asset.type', '=', sql.lit(AssetType.Video)) .where('asset.type', '=', sql.lit(AssetType.Video))
.$if(!force, (qb) => .$if(!force, (qb) =>
qb qb
@@ -353,8 +334,7 @@ export class AssetJobRepository {
return this.db return this.db
.selectFrom('asset') .selectFrom('asset')
.select(['asset.id', 'asset.ownerId', 'asset.originalPath']) .select(['asset.id', 'asset.ownerId', 'asset.originalPath'])
.select((eb) => withFiles(eb, AssetFileType.EncodedVideo)) .select(withFiles)
.select(withEdits)
.where('asset.id', '=', id) .where('asset.id', '=', id)
.where('asset.type', '=', sql.lit(AssetType.Video)) .where('asset.type', '=', sql.lit(AssetType.Video))
.executeTakeFirst(); .executeTakeFirst();
+3 -3
View File
@@ -1149,12 +1149,12 @@ export class AssetRepository {
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
} }
@GenerateSql({ params: [DummyValue.UUID, true] }) @GenerateSql({ params: [DummyValue.UUID] })
async getForVideo(id: string, isEdited: boolean) { async getForVideo(id: string) {
return this.db return this.db
.selectFrom('asset') .selectFrom('asset')
.select(['asset.originalPath']) .select(['asset.originalPath'])
.select((eb) => withFilePath(eb, AssetFileType.EncodedVideo, isEdited).as('encodedVideoPath')) .select((eb) => withFilePath(eb, AssetFileType.EncodedVideo).as('encodedVideoPath'))
.where('asset.id', '=', id) .where('asset.id', '=', id)
.where('asset.type', '=', AssetType.Video) .where('asset.type', '=', AssetType.Video)
.executeTakeFirst(); .executeTakeFirst();
@@ -695,9 +695,7 @@ describe(AssetMediaService.name, () => {
describe('playbackVideo', () => { describe('playbackVideo', () => {
it('should require asset.view permissions', async () => { it('should require asset.view permissions', async () => {
await expect(sut.playbackVideo(authStub.admin, 'id', { edited: true })).rejects.toBeInstanceOf( await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException);
BadRequestException,
);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']), undefined); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']), undefined);
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
@@ -708,9 +706,7 @@ describe(AssetMediaService.name, () => {
const asset = AssetFactory.create(); const asset = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
await expect(sut.playbackVideo(authStub.admin, asset.id, { edited: true })).rejects.toBeInstanceOf( await expect(sut.playbackVideo(authStub.admin, asset.id)).rejects.toBeInstanceOf(NotFoundException);
NotFoundException,
);
}); });
it('should return the encoded video path if available', async () => { it('should return the encoded video path if available', async () => {
@@ -723,7 +719,7 @@ describe(AssetMediaService.name, () => {
encodedVideoPath: asset.files[0].path, encodedVideoPath: asset.files[0].path,
}); });
await expect(sut.playbackVideo(authStub.admin, asset.id, { edited: true })).resolves.toEqual( await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual(
new ImmichFileResponse({ new ImmichFileResponse({
path: '/path/to/encoded/video.mp4', path: '/path/to/encoded/video.mp4',
cacheControl: CacheControl.PrivateWithCache, cacheControl: CacheControl.PrivateWithCache,
@@ -740,7 +736,7 @@ describe(AssetMediaService.name, () => {
encodedVideoPath: null, encodedVideoPath: null,
}); });
await expect(sut.playbackVideo(authStub.admin, asset.id, { edited: true })).resolves.toEqual( await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual(
new ImmichFileResponse({ new ImmichFileResponse({
path: asset.originalPath, path: asset.originalPath,
cacheControl: CacheControl.PrivateWithCache, cacheControl: CacheControl.PrivateWithCache,
+3 -4
View File
@@ -17,7 +17,6 @@ import {
AssetMediaOptionsDto, AssetMediaOptionsDto,
AssetMediaReplaceDto, AssetMediaReplaceDto,
AssetMediaSize, AssetMediaSize,
AssetThumbnailOptionsDto,
CheckExistingAssetsDto, CheckExistingAssetsDto,
UploadFieldName, UploadFieldName,
} from 'src/dtos/asset-media.dto'; } from 'src/dtos/asset-media.dto';
@@ -223,7 +222,7 @@ export class AssetMediaService extends BaseService {
async viewThumbnail( async viewThumbnail(
auth: AuthDto, auth: AuthDto,
id: string, id: string,
dto: AssetThumbnailOptionsDto, dto: AssetMediaOptionsDto,
): Promise<ImmichFileResponse | AssetMediaRedirectResponse> { ): Promise<ImmichFileResponse | AssetMediaRedirectResponse> {
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] }); await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
@@ -267,10 +266,10 @@ export class AssetMediaService extends BaseService {
}); });
} }
async playbackVideo(auth: AuthDto, id: string, dto: AssetMediaOptionsDto): Promise<ImmichFileResponse> { async playbackVideo(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] }); await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
const asset = await this.assetRepository.getForVideo(id, dto.edited ?? false); const asset = await this.assetRepository.getForVideo(id);
if (!asset) { if (!asset) {
throw new NotFoundException('Asset not found or asset is not a video'); throw new NotFoundException('Asset not found or asset is not a video');
+6 -32
View File
@@ -47,7 +47,6 @@ import {
} from 'src/utils/asset.util'; } from 'src/utils/asset.util';
import { updateLockedColumns } from 'src/utils/database'; import { updateLockedColumns } from 'src/utils/database';
import { extractTimeZone } from 'src/utils/date'; import { extractTimeZone } from 'src/utils/date';
import { scaleEdits } from 'src/utils/editor';
import { transformOcrBoundingBox } from 'src/utils/transform'; import { transformOcrBoundingBox } from 'src/utils/transform';
@Injectable() @Injectable()
@@ -566,6 +565,10 @@ export class AssetService extends BaseService {
throw new BadRequestException('Only images can be edited'); throw new BadRequestException('Only images can be edited');
} }
if (asset.livePhotoVideoId) {
throw new BadRequestException('Editing live photos is not supported');
}
if (isPanorama(asset)) { if (isPanorama(asset)) {
throw new BadRequestException('Editing panorama images is not supported'); throw new BadRequestException('Editing panorama images is not supported');
} }
@@ -606,28 +609,7 @@ export class AssetService extends BaseService {
} }
const newEdits = await this.assetEditRepository.replaceAll(id, edits); const newEdits = await this.assetEditRepository.replaceAll(id, edits);
await this.jobRepository.queue({ name: JobName.AssetProcessEdit, data: { id } }); await this.jobRepository.queue({ name: JobName.AssetEditThumbnailGeneration, data: { id } });
if (asset.livePhotoVideoId) {
const liveAsset = await this.assetRepository.getForEdit(asset.livePhotoVideoId);
if (!liveAsset) {
throw new BadRequestException('Live photo video not found');
}
const { width: liveWidth, height: liveHeight } = getDimensions(liveAsset);
const scaledEdits = scaleEdits(
edits,
{ width: liveWidth, height: liveHeight },
{ width: assetWidth, height: assetHeight },
);
await this.assetEditRepository.replaceAll(asset.livePhotoVideoId, scaledEdits);
await this.jobRepository.queue({
name: JobName.AssetProcessEdit,
data: { id: asset.livePhotoVideoId },
});
}
// Return the asset and its applied edits // Return the asset and its applied edits
return { return {
@@ -645,14 +627,6 @@ export class AssetService extends BaseService {
} }
await this.assetEditRepository.replaceAll(id, []); await this.assetEditRepository.replaceAll(id, []);
await this.jobRepository.queue({ name: JobName.AssetProcessEdit, data: { id } }); await this.jobRepository.queue({ name: JobName.AssetEditThumbnailGeneration, data: { id } });
if (asset.livePhotoVideoId) {
await this.assetEditRepository.replaceAll(asset.livePhotoVideoId, []);
await this.jobRepository.queue({
name: JobName.AssetProcessEdit,
data: { id: asset.livePhotoVideoId },
});
}
} }
} }
+2 -1
View File
@@ -95,7 +95,8 @@ export class JobService extends BaseService {
} }
break; break;
} }
case JobName.AssetProcessEdit: {
case JobName.AssetEditThumbnailGeneration: {
const asset = await this.assetRepository.getById(item.data.id); const asset = await this.assetRepository.getById(item.data.id);
const edits = await this.assetEditRepository.getWithSyncInfo(item.data.id); const edits = await this.assetEditRepository.getWithSyncInfo(item.data.id);
+29 -323
View File
@@ -221,7 +221,7 @@ describe(MediaService.name, () => {
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.AssetProcessEdit, name: JobName.AssetEditThumbnailGeneration,
data: { id: asset.id }, data: { id: asset.id },
}, },
]); ]);
@@ -273,7 +273,7 @@ describe(MediaService.name, () => {
data: { id: asset.id }, data: { id: asset.id },
}, },
{ {
name: JobName.AssetProcessEdit, name: JobName.AssetEditThumbnailGeneration,
data: { id: asset.id }, data: { id: asset.id },
}, },
]); ]);
@@ -1321,101 +1321,9 @@ describe(MediaService.name, () => {
expect.stringContaining('fullsize.jpeg'), expect.stringContaining('fullsize.jpeg'),
); );
}); });
it('should generate edited video thumbnails when asset has edits', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' })
.exif()
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 500, x: 0, y: 0 } })
.build();
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
await sut.handleGenerateThumbnails({ id: asset.id });
// should generate both original and edited thumbnails (2 original + 2 edited transcodes)
expect(mocks.media.transcode).toHaveBeenCalledTimes(4);
// should upsert files for both original and edited
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ type: AssetFileType.Preview, isEdited: false }),
expect.objectContaining({ type: AssetFileType.Thumbnail, isEdited: false }),
expect.objectContaining({ type: AssetFileType.Preview, isEdited: true }),
expect.objectContaining({ type: AssetFileType.Thumbnail, isEdited: true }),
]),
);
});
it('should not generate edited video thumbnails when asset has no edits', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' }).exif().build();
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
mocks.media.generateThumbhash.mockResolvedValue(Buffer.from('thumbhash'));
await sut.handleGenerateThumbnails({ id: asset.id });
// should only generate original thumbnails (2 transcodes for preview + thumbnail)
expect(mocks.media.transcode).toHaveBeenCalledTimes(2);
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith(
expect.not.arrayContaining([expect.objectContaining({ isEdited: true })]),
);
});
it('should use edited thumbhash when asset has edits', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' })
.exif()
.edit({ action: AssetEditAction.Crop })
.build();
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
const originalThumbhash = Buffer.from('original thumbhash');
const editedThumbhash = Buffer.from('edited thumbhash');
mocks.media.generateThumbhash.mockResolvedValueOnce(originalThumbhash).mockResolvedValueOnce(editedThumbhash);
await sut.handleGenerateThumbnails({ id: asset.id });
// should use the edited thumbhash (second call) for the asset update
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ thumbhash: editedThumbhash }));
});
it('should generate edited image thumbnails with edits applied', async () => {
const asset = AssetFactory.from()
.exif()
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 500, x: 100, y: 100 } })
.build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
await sut.handleGenerateThumbnails({ id: asset.id });
// should generate original (2) + edited (3 with fullsize) thumbnails
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
expect.objectContaining({
edits: [
expect.objectContaining({
action: 'crop',
parameters: { height: 500, width: 500, x: 100, y: 100 },
}),
],
}),
expect.stringContaining('edited'),
);
// should upsert both original and edited files
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ isEdited: false }),
expect.objectContaining({ isEdited: true }),
]),
);
});
}); });
describe('handleAssetEditProcessing', () => { describe('handleAssetEditThumbnailGeneration', () => {
let rawInfo: RawImageInfo; let rawInfo: RawImageInfo;
beforeEach(() => { beforeEach(() => {
@@ -1432,6 +1340,14 @@ describe(MediaService.name, () => {
mocks.media.getImageMetadata.mockResolvedValue({ width: 100, height: 100, isTransparent: false }); mocks.media.getImageMetadata.mockResolvedValue({ width: 100, height: 100, isTransparent: false });
}); });
it('should skip videos', async () => {
const asset = AssetFactory.from({ type: AssetType.Video }).exif().build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await expect(sut.handleAssetEditThumbnailGeneration({ id: asset.id })).resolves.toBe(JobStatus.Success);
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
});
it('should upsert 3 edited files for edit jobs', async () => { it('should upsert 3 edited files for edit jobs', async () => {
const asset = AssetFactory.from() const asset = AssetFactory.from()
.exif() .exif()
@@ -1443,13 +1359,13 @@ describe(MediaService.name, () => {
]) ])
.build(); .build();
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset)); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
mocks.person.getFaces.mockResolvedValue([]); mocks.person.getFaces.mockResolvedValue([]);
mocks.ocr.getByAssetId.mockResolvedValue([]); mocks.ocr.getByAssetId.mockResolvedValue([]);
await sut.handleAssetEditProcessing({ id: asset.id }); await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith( expect(mocks.asset.upsertFiles).toHaveBeenCalledWith(
expect.arrayContaining([ expect.arrayContaining([
@@ -1465,11 +1381,11 @@ describe(MediaService.name, () => {
.exif() .exif()
.edit({ action: AssetEditAction.Crop, parameters: { height: 1152, width: 1512, x: 216, y: 1512 } }) .edit({ action: AssetEditAction.Crop, parameters: { height: 1152, width: 1512, x: 216, y: 1512 } })
.build(); .build();
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset)); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
mocks.person.getFaces.mockResolvedValue([]); mocks.person.getFaces.mockResolvedValue([]);
mocks.ocr.getByAssetId.mockResolvedValue([]); mocks.ocr.getByAssetId.mockResolvedValue([]);
await sut.handleAssetEditProcessing({ id: asset.id }); await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer, rawBuffer,
expect.objectContaining({ expect.objectContaining({
@@ -1493,9 +1409,9 @@ describe(MediaService.name, () => {
{ type: AssetFileType.FullSize, path: 'edited3.jpg', isEdited: true }, { type: AssetFileType.FullSize, path: 'edited3.jpg', isEdited: true },
]) ])
.build(); .build();
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset)); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
const status = await sut.handleAssetEditProcessing({ id: asset.id }); const status = await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete, name: JobName.FileDelete,
@@ -1511,11 +1427,11 @@ describe(MediaService.name, () => {
it('should generate all 3 edited files if an asset has edits', async () => { it('should generate all 3 edited files if an asset has edits', async () => {
const asset = AssetFactory.from().exif().edit().build(); const asset = AssetFactory.from().exif().edit().build();
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset)); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
mocks.person.getFaces.mockResolvedValue([]); mocks.person.getFaces.mockResolvedValue([]);
mocks.ocr.getByAssetId.mockResolvedValue([]); mocks.ocr.getByAssetId.mockResolvedValue([]);
await sut.handleAssetEditProcessing({ id: asset.id }); await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3); expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
@@ -1537,147 +1453,26 @@ describe(MediaService.name, () => {
it('should generate the original thumbhash if no edits exist', async () => { it('should generate the original thumbhash if no edits exist', async () => {
const asset = AssetFactory.from().exif().build(); const asset = AssetFactory.from().exif().build();
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset)); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
mocks.media.generateThumbhash.mockResolvedValue(factory.buffer()); mocks.media.generateThumbhash.mockResolvedValue(factory.buffer());
await sut.handleAssetEditProcessing({ id: asset.id, source: 'upload' }); await sut.handleAssetEditThumbnailGeneration({ id: asset.id, source: 'upload' });
expect(mocks.media.generateThumbhash).toHaveBeenCalled(); expect(mocks.media.generateThumbhash).toHaveBeenCalled();
}); });
it('should apply thumbhash if job source is edit and edits exist', async () => { it('should apply thumbhash if job source is edit and edits exist', async () => {
const asset = AssetFactory.from().exif().edit().build(); const asset = AssetFactory.from().exif().edit().build();
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset)); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
const thumbhashBuffer = factory.buffer(); const thumbhashBuffer = factory.buffer();
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
mocks.person.getFaces.mockResolvedValue([]); mocks.person.getFaces.mockResolvedValue([]);
mocks.ocr.getByAssetId.mockResolvedValue([]); mocks.ocr.getByAssetId.mockResolvedValue([]);
await sut.handleAssetEditProcessing({ id: asset.id }); await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ thumbhash: thumbhashBuffer })); expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ thumbhash: thumbhashBuffer }));
}); });
it('should return failed if asset not found', async () => {
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(undefined as never);
const status = await sut.handleAssetEditProcessing({ id: 'non-existent' });
expect(status).toBe(JobStatus.Failed);
});
it('should transcode edited video and generate thumbnails', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' })
.exif()
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 500, x: 0, y: 0 } })
.files([
{ type: AssetFileType.Preview, isEdited: false },
{ type: AssetFileType.EncodedVideo, isEdited: true },
{ type: AssetFileType.Preview, isEdited: true },
{ type: AssetFileType.Thumbnail, isEdited: true },
])
.build();
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
await sut.handleAssetEditProcessing({ id: asset.id });
// should transcode the video with hw accel disabled
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/video.mp4',
expect.stringContaining('edited'),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.any(Array),
}),
);
// should generate edited thumbnails (preview + thumbnail via transcode)
expect(mocks.media.transcode).toHaveBeenCalledTimes(3); // 1 video + 2 thumbnails
// should update thumbhash
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ thumbhash: thumbhashBuffer }));
});
it('should clean up edited video files when asset has no edits', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' })
.exif()
.files([
{ type: AssetFileType.EncodedVideo, path: 'edited_video.mp4', isEdited: true },
{ type: AssetFileType.Preview, path: 'edited_preview.jpg', isEdited: true },
{ type: AssetFileType.Thumbnail, path: 'edited_thumbnail.webp', isEdited: true },
])
.build();
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
mocks.media.generateThumbhash.mockResolvedValue(factory.buffer());
await sut.handleAssetEditProcessing({ id: asset.id });
// should not transcode since there are no edits
expect(mocks.media.transcode).not.toHaveBeenCalled();
// should delete old edited files
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete,
data: {
files: expect.arrayContaining(['edited_video.mp4']),
},
});
});
it('should skip thumbnail generation for hidden video assets (live photo video portions)', async () => {
const asset = AssetFactory.from({
type: AssetType.Video,
originalPath: '/original/video.mp4',
visibility: AssetVisibility.Hidden,
})
.exif()
.edit({ action: AssetEditAction.Crop })
.files([{ type: AssetFileType.Preview, isEdited: false }])
.build();
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
await sut.handleAssetEditProcessing({ id: asset.id });
expect(mocks.media.transcode).toHaveBeenCalledTimes(1);
expect(mocks.media.generateThumbhash).not.toHaveBeenCalled();
});
it('should use original thumbhash when video has no edits but is visible', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' })
.exif()
.files([{ type: AssetFileType.Preview, path: '/thumbs/preview.jpg', isEdited: false }])
.build();
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
const thumbhashBuffer = factory.buffer();
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
await sut.handleAssetEditProcessing({ id: asset.id });
expect(mocks.media.generateThumbhash).toHaveBeenCalledWith('/thumbs/preview.jpg', expect.any(Object));
expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({ id: asset.id, thumbhash: thumbhashBuffer }),
);
});
it('should update dimensions from transcoded video edit', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' })
.exif()
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 800, x: 100, y: 100 } })
.files([{ type: AssetFileType.Preview, isEdited: false }])
.build();
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
mocks.media.generateThumbhash.mockResolvedValue(factory.buffer());
await sut.handleAssetEditProcessing({ id: asset.id });
// should update asset dimensions
expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({ id: asset.id, width: 800, height: 500 }),
);
});
}); });
describe('handleGeneratePersonThumbnail', () => { describe('handleGeneratePersonThumbnail', () => {
@@ -2179,7 +1974,7 @@ describe(MediaService.name, () => {
mocks.media.probe.mockResolvedValue(probeStub.noAudioStreams); mocks.media.probe.mockResolvedValue(probeStub.noAudioStreams);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: 'foo' } } as never as SystemConfig); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: 'foo' } } as never as SystemConfig);
await expect(sut.handleVideoConversion({ id: 'video-id' })).resolves.toBe(JobStatus.Failed); await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError();
expect(mocks.media.transcode).not.toHaveBeenCalled(); expect(mocks.media.transcode).not.toHaveBeenCalled();
}); });
@@ -2433,7 +2228,7 @@ describe(MediaService.name, () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: 'invalid' as any } }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: 'invalid' as any } });
await expect(sut.handleVideoConversion({ id: 'video-id' })).resolves.toBe(JobStatus.Failed); await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError();
expect(mocks.media.transcode).not.toHaveBeenCalled(); expect(mocks.media.transcode).not.toHaveBeenCalled();
}); });
@@ -2831,14 +2626,14 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({ mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, targetVideoCodec: VideoCodec.Vp9 }, ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, targetVideoCodec: VideoCodec.Vp9 },
}); });
await expect(sut.handleVideoConversion({ id: 'video-id' })).resolves.toBe(JobStatus.Failed); await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError();
expect(mocks.media.transcode).not.toHaveBeenCalled(); expect(mocks.media.transcode).not.toHaveBeenCalled();
}); });
it('should fail if hwaccel option is invalid', async () => { it('should fail if hwaccel option is invalid', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: 'invalid' as any } }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: 'invalid' as any } });
await expect(sut.handleVideoConversion({ id: 'video-id' })).resolves.toBe(JobStatus.Failed); await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError();
expect(mocks.media.transcode).not.toHaveBeenCalled(); expect(mocks.media.transcode).not.toHaveBeenCalled();
}); });
@@ -3125,7 +2920,7 @@ describe(MediaService.name, () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv } }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv } });
await expect(sut.handleVideoConversion({ id: 'video-id' })).resolves.toBe(JobStatus.Failed); await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError();
expect(mocks.media.transcode).not.toHaveBeenCalled(); expect(mocks.media.transcode).not.toHaveBeenCalled();
}); });
@@ -3535,7 +3330,7 @@ describe(MediaService.name, () => {
sut.videoInterfaces = { dri: [], mali: true }; sut.videoInterfaces = { dri: [], mali: true };
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi } }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi } });
await expect(sut.handleVideoConversion({ id: 'video-id' })).resolves.toBe(JobStatus.Failed); await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError();
expect(mocks.media.transcode).not.toHaveBeenCalled(); expect(mocks.media.transcode).not.toHaveBeenCalled();
}); });
@@ -3810,95 +3605,6 @@ describe(MediaService.name, () => {
}), }),
); );
}); });
it('should also transcode edited version when asset has edits', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' })
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 500, x: 0, y: 0 } })
.build();
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
await sut.handleVideoConversion({ id: asset.id });
// should be called for both original and edited
expect(mocks.media.probe).toHaveBeenCalledTimes(2);
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
expect.stringContaining('edited'),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.any(Array),
}),
);
});
it('should not transcode edited version when asset has no edits', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).build();
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
await sut.handleVideoConversion({ id: asset.id });
// probe is called for both original and edit attempt, but only original is transcoded
expect(mocks.media.transcode).toHaveBeenCalledTimes(1);
expect(mocks.asset.upsertFiles).not.toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ isEdited: true })]),
);
});
it('should disable hardware acceleration for edited video transcoding', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' })
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 500, x: 0, y: 0 } })
.build();
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, transcode: TranscodePolicy.All },
});
await sut.handleVideoConversion({ id: asset.id });
// the edited transcode call should NOT have hw accel options
const transcodeCalls = mocks.media.transcode.mock.calls;
const editedCall = transcodeCalls.find((call) => (call[1] as string).includes('edited'));
expect(editedCall).toBeDefined();
// hw accel typically adds device-specific input options; for edited, should be software only
expect(editedCall![2].inputOptions).not.toEqual(expect.arrayContaining([expect.stringContaining('qsv')]));
});
it('should upsert both original and edited encoded video files', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' })
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 500, x: 0, y: 0 } })
.build();
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
await sut.handleVideoConversion({ id: asset.id });
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ type: AssetFileType.EncodedVideo, isEdited: false }),
expect.objectContaining({ type: AssetFileType.EncodedVideo, isEdited: true }),
]),
);
});
it('should clean up edited encoded video when edits are removed', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' })
.file({ type: AssetFileType.EncodedVideo, path: '/encoded/edited_video.mp4', isEdited: true })
.build();
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
await sut.handleVideoConversion({ id: asset.id });
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete,
data: {
files: expect.arrayContaining(['/encoded/edited_video.mp4']),
},
});
});
}); });
describe('isSRGB', () => { describe('isSRGB', () => {
+72 -241
View File
@@ -4,7 +4,7 @@ import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core'; import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
import { AssetFile } from 'src/database'; import { AssetFile } from 'src/database';
import { OnEvent, OnJob } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { AssetEditAction, AssetEditActionItem, CropParameters } from 'src/dtos/editing.dto'; import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import { import {
AssetFileType, AssetFileType,
@@ -39,7 +39,7 @@ import {
VideoInterfaces, VideoInterfaces,
VideoStreamInfo, VideoStreamInfo,
} from 'src/types'; } from 'src/types';
import { getDimensions } from 'src/utils/asset.util'; import { getAssetFile, getDimensions } from 'src/utils/asset.util';
import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor'; import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor';
import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
import { mimeTypes } from 'src/utils/mime-types'; import { mimeTypes } from 'src/utils/mime-types';
@@ -56,13 +56,6 @@ interface UpsertFileOptions {
} }
type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>; type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>;
type VideoConversionAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForVideoConversion']>>>;
type ThumbnailGenerationResult = {
files: UpsertFileOptions[];
thumbhash: Buffer;
fullsizeDimensions: ImageDimensions;
};
@Injectable() @Injectable()
export class MediaService extends BaseService { export class MediaService extends BaseService {
@@ -91,7 +84,7 @@ export class MediaService extends BaseService {
} }
if (asset.isEdited) { if (asset.isEdited) {
jobs.push({ name: JobName.AssetProcessEdit, data: { id: asset.id } }); jobs.push({ name: JobName.AssetEditThumbnailGeneration, data: { id: asset.id } });
} }
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
@@ -175,9 +168,9 @@ export class MediaService extends BaseService {
return JobStatus.Success; return JobStatus.Success;
} }
@OnJob({ name: JobName.AssetProcessEdit, queue: QueueName.Editor }) @OnJob({ name: JobName.AssetEditThumbnailGeneration, queue: QueueName.Editor })
async handleAssetEditProcessing({ id }: JobOf<JobName.AssetProcessEdit>): Promise<JobStatus> { async handleAssetEditThumbnailGeneration({ id }: JobOf<JobName.AssetEditThumbnailGeneration>): Promise<JobStatus> {
const asset = await this.assetJobRepository.getForAssetEditProcessing(id); const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id);
const config = await this.getConfig({ withCache: true }); const config = await this.getConfig({ withCache: true });
if (!asset) { if (!asset) {
@@ -185,25 +178,7 @@ export class MediaService extends BaseService {
return JobStatus.Failed; return JobStatus.Failed;
} }
switch (asset.type) { const generated = await this.generateEditedThumbnails(asset, config);
case AssetType.Image: {
await this.handleImageEdit(asset, config);
break;
}
case AssetType.Video: {
await this.handleVideoEdit(asset, config);
break;
}
default: {
this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`);
}
}
return JobStatus.Success;
}
private async handleImageEdit(asset: ThumbnailAsset, config: SystemConfig) {
const generated = await this.generateEditedImageThumbnails(asset, config);
await this.syncFiles( await this.syncFiles(
asset.files.filter((file) => file.isEdited), asset.files.filter((file) => file.isEdited),
generated?.files ?? [], generated?.files ?? [],
@@ -228,51 +203,8 @@ export class MediaService extends BaseService {
const fullsizeDimensions = generated?.fullsizeDimensions ?? getDimensions(asset.exifInfo!); const fullsizeDimensions = generated?.fullsizeDimensions ?? getDimensions(asset.exifInfo!);
await this.assetRepository.update({ id: asset.id, ...fullsizeDimensions }); await this.assetRepository.update({ id: asset.id, ...fullsizeDimensions });
}
private async handleVideoEdit(asset: ThumbnailAsset, config: SystemConfig) { return JobStatus.Success;
// transcode edited video
const generatedVideo = asset.edits.length > 0 ? await this.transcodeVideo(asset, config.ffmpeg, true) : undefined;
await this.syncFiles(
asset.files.filter((file) => file.isEdited && file.type === AssetFileType.EncodedVideo),
generatedVideo ? [generatedVideo.file] : [],
);
// update asset dimensions
const newDimensions = generatedVideo?.dimensions ?? getDimensions(asset.exifInfo!);
await this.assetRepository.update({ id: asset.id, ...newDimensions });
// if the asset is hidden, we dont need to update the thumbhash or thumbnails
if (asset.visibility === AssetVisibility.Hidden) {
return;
}
const editedThumbnails = await this.generateEditedVideoThumbnails(asset, config);
await this.syncFiles(
asset.files.filter((file) => file.isEdited && file.type !== AssetFileType.EncodedVideo),
editedThumbnails?.files ?? [],
);
let thumbhash: Buffer | undefined = editedThumbnails?.thumbhash;
if (!thumbhash) {
const previewFile = asset.files.find((file) => file.type === AssetFileType.Preview && !file.isEdited);
if (!previewFile) {
this.logger.warn(`Failed to generate thumbhash for asset ${asset.id}: missing preview file`);
return;
}
thumbhash = await this.mediaRepository.generateThumbhash(previewFile.path, {
colorspace: config.image.colorspace,
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
});
}
// update asset table info
if (!asset.thumbhash || Buffer.compare(asset.thumbhash, thumbhash) !== 0) {
await this.assetRepository.update({ id: asset.id, thumbhash });
}
} }
@OnJob({ name: JobName.AssetGenerateThumbnails, queue: QueueName.ThumbnailGeneration }) @OnJob({ name: JobName.AssetGenerateThumbnails, queue: QueueName.ThumbnailGeneration })
@@ -285,34 +217,31 @@ export class MediaService extends BaseService {
return JobStatus.Failed; return JobStatus.Failed;
} }
let generated: ThumbnailGenerationResult;
let generatedEdited: ThumbnailGenerationResult | undefined;
if (asset.visibility === AssetVisibility.Hidden) { if (asset.visibility === AssetVisibility.Hidden) {
this.logger.verbose(`Thumbnail generation skipped for asset ${id}: not visible`); this.logger.verbose(`Thumbnail generation skipped for asset ${id}: not visible`);
return JobStatus.Skipped; return JobStatus.Skipped;
} }
let generated: Awaited<ReturnType<MediaService['generateImageThumbnails']>>;
if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) { if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) {
this.logger.verbose(`Thumbnail generation for video ${id} ${asset.originalPath}`); this.logger.verbose(`Thumbnail generation for video ${id} ${asset.originalPath}`);
generated = await this.generateVideoThumbnails(asset, config); generated = await this.generateVideoThumbnails(asset, config);
generatedEdited = await this.generateEditedVideoThumbnails(asset, config);
} else if (asset.type === AssetType.Image) { } else if (asset.type === AssetType.Image) {
this.logger.verbose(`Thumbnail generation for image ${id} ${asset.originalPath}`); this.logger.verbose(`Thumbnail generation for image ${id} ${asset.originalPath}`);
generated = await this.generateImageThumbnails(asset, config); generated = await this.generateImageThumbnails(asset, config);
generatedEdited = await this.generateEditedImageThumbnails(asset, config);
} else { } else {
this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`); this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`);
return JobStatus.Skipped; return JobStatus.Skipped;
} }
if (generatedEdited) { const editedGenerated = await this.generateEditedThumbnails(asset, config);
generated.files.push(...generatedEdited.files); if (editedGenerated) {
generated.files.push(...editedGenerated.files);
} }
await this.syncFiles(asset.files, generated.files); await this.syncFiles(asset.files, generated.files);
const thumbhash = editedGenerated?.thumbhash || generated.thumbhash;
const thumbhash = generatedEdited?.thumbhash || generated.thumbhash;
if (!asset.thumbhash || Buffer.compare(asset.thumbhash, thumbhash) !== 0) { if (!asset.thumbhash || Buffer.compare(asset.thumbhash, thumbhash) !== 0) {
await this.assetRepository.update({ id: asset.id, thumbhash }); await this.assetRepository.update({ id: asset.id, thumbhash });
} }
@@ -578,21 +507,20 @@ export class MediaService extends BaseService {
} }
private async generateVideoThumbnails( private async generateVideoThumbnails(
asset: ThumbnailPathEntity & { originalPath: string; edits: AssetEditActionItem[] }, asset: ThumbnailPathEntity & { originalPath: string },
{ ffmpeg, image }: SystemConfig, { ffmpeg, image }: SystemConfig,
useEdits: boolean = false,
) { ) {
const previewFile = this.getImageFile(asset, { const previewFile = this.getImageFile(asset, {
fileType: AssetFileType.Preview, fileType: AssetFileType.Preview,
format: image.preview.format, format: image.preview.format,
isEdited: useEdits, isEdited: false,
isProgressive: false, isProgressive: false,
isTransparent: false, isTransparent: false,
}); });
const thumbnailFile = this.getImageFile(asset, { const thumbnailFile = this.getImageFile(asset, {
fileType: AssetFileType.Thumbnail, fileType: AssetFileType.Thumbnail,
format: image.thumbnail.format, format: image.thumbnail.format,
isEdited: useEdits, isEdited: false,
isProgressive: false, isProgressive: false,
isTransparent: false, isTransparent: false,
}); });
@@ -605,27 +533,14 @@ export class MediaService extends BaseService {
} }
const mainAudioStream = this.getMainStream(audioStreams); const mainAudioStream = this.getMainStream(audioStreams);
let edits: AssetEditActionItem[] | undefined;
if (useEdits) {
ffmpeg = { ...ffmpeg, accel: TranscodeHardwareAcceleration.Disabled };
edits = asset.edits;
}
const previewConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.preview.size.toString() }); const previewConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.preview.size.toString() });
const thumbnailConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() }); const thumbnailConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() });
const previewOptions = previewConfig.getCommand( const previewOptions = previewConfig.getCommand(TranscodeTarget.Video, mainVideoStream, mainAudioStream, format);
TranscodeTarget.Video,
mainVideoStream,
mainAudioStream,
format,
edits,
);
const thumbnailOptions = thumbnailConfig.getCommand( const thumbnailOptions = thumbnailConfig.getCommand(
TranscodeTarget.Video, TranscodeTarget.Video,
mainVideoStream, mainVideoStream,
mainAudioStream, mainAudioStream,
format, format,
edits,
); );
await this.mediaRepository.transcode(asset.originalPath, previewFile.path, previewOptions); await this.mediaRepository.transcode(asset.originalPath, previewFile.path, previewOptions);
@@ -636,69 +551,73 @@ export class MediaService extends BaseService {
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
}); });
let fullsizeDimensions = { width: mainVideoStream.width, height: mainVideoStream.height };
if (useEdits) {
fullsizeDimensions = getOutputDimensions(asset.edits, fullsizeDimensions);
}
return { return {
files: [previewFile, thumbnailFile], files: [previewFile, thumbnailFile],
thumbhash, thumbhash,
fullsizeDimensions, fullsizeDimensions: { width: mainVideoStream.width, height: mainVideoStream.height },
}; };
} }
private async transcodeVideo( @OnJob({ name: JobName.AssetEncodeVideoQueueAll, queue: QueueName.VideoConversion })
asset: VideoConversionAsset, async handleQueueVideoConversion(job: JobOf<JobName.AssetEncodeVideoQueueAll>): Promise<JobStatus> {
ffmpeg: SystemConfigFFmpegDto, const { force } = job;
useEdits: boolean = false,
): Promise<{ file: UpsertFileOptions; dimensions: { width: number; height: number } } | undefined> { let queue: { name: JobName.AssetEncodeVideo; data: { id: string } }[] = [];
for await (const asset of this.assetJobRepository.streamForVideoConversion(force)) {
queue.push({ name: JobName.AssetEncodeVideo, data: { id: asset.id } });
if (queue.length >= JOBS_ASSET_PAGINATION_SIZE) {
await this.jobRepository.queueAll(queue);
queue = [];
}
}
await this.jobRepository.queueAll(queue);
return JobStatus.Success;
}
@OnJob({ name: JobName.AssetEncodeVideo, queue: QueueName.VideoConversion })
async handleVideoConversion({ id }: JobOf<JobName.AssetEncodeVideo>): Promise<JobStatus> {
const asset = await this.assetJobRepository.getForVideoConversion(id);
if (!asset) {
return JobStatus.Failed;
}
const input = asset.originalPath; const input = asset.originalPath;
const output = StorageCore.getEncodedVideoPath(asset, useEdits); const output = StorageCore.getEncodedVideoPath(asset);
this.storageCore.ensureFolders(output); this.storageCore.ensureFolders(output);
const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input, { const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input, {
countFrames: this.logger.isLevelEnabled(LogLevel.Debug), countFrames: this.logger.isLevelEnabled(LogLevel.Debug), // makes frame count more reliable for progress logs
}); });
const videoStream = this.getMainStream(videoStreams); const videoStream = this.getMainStream(videoStreams);
const audioStream = this.getMainStream(audioStreams); const audioStream = this.getMainStream(audioStreams);
if (!videoStream || !format.formatName) { if (!videoStream || !format.formatName) {
return undefined; return JobStatus.Failed;
} }
if (!videoStream.height || !videoStream.width) { if (!videoStream.height || !videoStream.width) {
this.logger.warn(`Skipped transcoding for asset ${asset.id}: no video streams found`); this.logger.warn(`Skipped transcoding for asset ${asset.id}: no video streams found`);
return undefined; return JobStatus.Failed;
} }
let target: TranscodeTarget; let { ffmpeg } = await this.getConfig({ withCache: true });
let edits: AssetEditActionItem[] | undefined; const target = this.getTranscodeTarget(ffmpeg, videoStream, audioStream);
if (target === TranscodeTarget.None && !this.isRemuxRequired(ffmpeg, format)) {
if (useEdits) { const encodedVideo = getAssetFile(asset.files, AssetFileType.EncodedVideo, { isEdited: false });
if (asset.edits.length === 0) { if (encodedVideo) {
this.logger.verbose(`Asset ${asset.id} has no edits, skipping edited version transcoding`); this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`);
return undefined; await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [encodedVideo.path] } });
} await this.assetRepository.deleteFiles([encodedVideo]);
} else {
ffmpeg = { ...ffmpeg, accel: TranscodeHardwareAcceleration.Disabled };
target = TranscodeTarget.All;
edits = asset.edits;
} else {
target = this.getTranscodeTarget(ffmpeg, videoStream, audioStream);
if (target === TranscodeTarget.None && !this.isRemuxRequired(ffmpeg, format)) {
this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`); this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`);
return undefined;
} }
return JobStatus.Skipped;
} }
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand( const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream);
target,
videoStream,
audioStream,
useEdits ? undefined : format,
edits,
);
if (ffmpeg.accel === TranscodeHardwareAcceleration.Disabled) { if (ffmpeg.accel === TranscodeHardwareAcceleration.Disabled) {
this.logger.log(`Transcoding video ${asset.id} without hardware acceleration`); this.logger.log(`Transcoding video ${asset.id} without hardware acceleration`);
} else { } else {
@@ -712,7 +631,7 @@ export class MediaService extends BaseService {
} catch (error: any) { } catch (error: any) {
this.logger.error(`Error occurred during transcoding: ${error.message}`); this.logger.error(`Error occurred during transcoding: ${error.message}`);
if (ffmpeg.accel === TranscodeHardwareAcceleration.Disabled) { if (ffmpeg.accel === TranscodeHardwareAcceleration.Disabled) {
throw error; return JobStatus.Failed;
} }
let partialFallbackSuccess = false; let partialFallbackSuccess = false;
@@ -720,13 +639,7 @@ export class MediaService extends BaseService {
try { try {
this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()}-accelerated encoding and software decoding`); this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()}-accelerated encoding and software decoding`);
ffmpeg = { ...ffmpeg, accelDecode: false }; ffmpeg = { ...ffmpeg, accelDecode: false };
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand( const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream);
target,
videoStream,
audioStream,
format,
edits,
);
await this.mediaRepository.transcode(input, output, command); await this.mediaRepository.transcode(input, output, command);
partialFallbackSuccess = true; partialFallbackSuccess = true;
} catch (error: any) { } catch (error: any) {
@@ -737,87 +650,19 @@ export class MediaService extends BaseService {
if (!partialFallbackSuccess) { if (!partialFallbackSuccess) {
this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled`); this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled`);
ffmpeg = { ...ffmpeg, accel: TranscodeHardwareAcceleration.Disabled }; ffmpeg = { ...ffmpeg, accel: TranscodeHardwareAcceleration.Disabled };
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand( const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream);
target,
videoStream,
audioStream,
format,
edits,
);
await this.mediaRepository.transcode(input, output, command); await this.mediaRepository.transcode(input, output, command);
} }
} }
this.logger.log(`Successfully encoded ${asset.id}`); this.logger.log(`Successfully encoded ${asset.id}`);
let finalDimensions = { width: videoStream.width, height: videoStream.height }; await this.assetRepository.upsertFile({
if (useEdits) { assetId: asset.id,
finalDimensions = getOutputDimensions(asset.edits, finalDimensions); type: AssetFileType.EncodedVideo,
} path: output,
isEdited: false,
return { });
dimensions: finalDimensions,
file: {
assetId: asset.id,
type: AssetFileType.EncodedVideo,
path: output,
isEdited: useEdits,
isProgressive: false,
isTransparent: false,
},
};
}
@OnJob({ name: JobName.AssetEncodeVideoQueueAll, queue: QueueName.VideoConversion })
async handleQueueVideoConversion(job: JobOf<JobName.AssetEncodeVideoQueueAll>): Promise<JobStatus> {
const { force } = job;
let jobs: JobItem[] = [];
for await (const asset of this.assetJobRepository.streamForVideoConversion(force)) {
if (force || !asset.isEdited) {
jobs.push({ name: JobName.AssetEncodeVideo, data: { id: asset.id } });
}
if (asset.isEdited) {
jobs.push({ name: JobName.AssetProcessEdit, data: { id: asset.id } });
}
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
await this.jobRepository.queueAll(jobs);
jobs = [];
}
}
await this.jobRepository.queueAll(jobs);
return JobStatus.Success;
}
@OnJob({ name: JobName.AssetEncodeVideo, queue: QueueName.VideoConversion })
async handleVideoConversion({ id }: JobOf<JobName.AssetEncodeVideo>): Promise<JobStatus> {
const asset = await this.assetJobRepository.getForVideoConversion(id);
if (!asset) {
return JobStatus.Failed;
}
const { ffmpeg } = await this.getConfig({ withCache: true });
const files: UpsertFileOptions[] = [];
try {
const generated = await this.transcodeVideo(asset, ffmpeg);
if (generated?.file) {
files.push(generated.file);
}
const editedGenerated = await this.transcodeVideo(asset, ffmpeg, true);
if (editedGenerated) {
files.push(editedGenerated.file);
}
} catch {
return JobStatus.Failed;
}
await this.syncFiles(asset.files, files);
return JobStatus.Success; return JobStatus.Success;
} }
@@ -1029,29 +874,13 @@ export class MediaService extends BaseService {
} }
} }
private async generateEditedImageThumbnails(asset: ThumbnailAsset, config: SystemConfig) { private async generateEditedThumbnails(asset: ThumbnailAsset, config: SystemConfig) {
if (asset.type !== AssetType.Image || (asset.files.length === 0 && asset.edits.length === 0)) { if (asset.type !== AssetType.Image || (asset.files.length === 0 && asset.edits.length === 0)) {
return; return;
} }
const generated = asset.edits.length > 0 ? await this.generateImageThumbnails(asset, config, true) : undefined; const generated = asset.edits.length > 0 ? await this.generateImageThumbnails(asset, config, true) : undefined;
await this.updateMLVisibilities(asset);
return generated;
}
private async generateEditedVideoThumbnails(asset: ThumbnailAsset, config: SystemConfig) {
if (asset.type !== AssetType.Video || (asset.files.length === 0 && asset.edits.length === 0)) {
return;
}
const generated = asset.edits.length > 0 ? await this.generateVideoThumbnails(asset, config, true) : undefined;
await this.updateMLVisibilities(asset);
return generated;
}
private async updateMLVisibilities(asset: ThumbnailAsset) {
const crop = asset.edits.find((e) => e.action === AssetEditAction.Crop); const crop = asset.edits.find((e) => e.action === AssetEditAction.Crop);
const cropBox = crop const cropBox = crop
? { ? {
@@ -1071,6 +900,8 @@ export class MediaService extends BaseService {
const ocrStatuses = checkOcrVisibility(ocrData, originalDimensions, cropBox); const ocrStatuses = checkOcrVisibility(ocrData, originalDimensions, cropBox);
await this.ocrRepository.updateOcrVisibilities(asset.id, ocrStatuses.visible, ocrStatuses.hidden); await this.ocrRepository.updateOcrVisibilities(asset.id, ocrStatuses.visible, ocrStatuses.hidden);
return generated;
} }
private warnOnTransparencyLoss(isTransparent: boolean, format: ImageFormat, assetId: string) { private warnOnTransparencyLoss(isTransparent: boolean, format: ImageFormat, assetId: string) {
+1 -3
View File
@@ -130,7 +130,6 @@ export interface TranscodeCommand {
progress: { progress: {
frameCount: number; frameCount: number;
percentInterval: number; percentInterval: number;
callback: (percent: number, frame: number) => void;
}; };
} }
@@ -152,7 +151,6 @@ export interface VideoCodecSWConfig {
videoStream: VideoStreamInfo, videoStream: VideoStreamInfo,
audioStream: AudioStreamInfo, audioStream: AudioStreamInfo,
format?: VideoFormat, format?: VideoFormat,
edits?: AssetEditActionItem[],
): TranscodeCommand; ): TranscodeCommand;
} }
@@ -391,7 +389,7 @@ export type JobItem =
| { name: JobName.WorkflowRun; data: IWorkflowJob } | { name: JobName.WorkflowRun; data: IWorkflowJob }
// Editor // Editor
| { name: JobName.AssetProcessEdit; data: IEntityJob }; | { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob };
export type VectorExtension = (typeof VECTOR_EXTENSIONS)[number]; export type VectorExtension = (typeof VECTOR_EXTENSIONS)[number];
+2 -14
View File
@@ -190,13 +190,7 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
} }
case Permission.AlbumUpdate: { case Permission.AlbumUpdate: {
const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); return await access.album.checkOwnerAccess(auth.user.id, ids);
const isShared = await access.album.checkSharedAlbumAccess(
auth.user.id,
setDifference(ids, isOwner),
AlbumUserRole.Editor,
);
return setUnion(isOwner, isShared);
} }
case Permission.AlbumDelete: { case Permission.AlbumDelete: {
@@ -204,13 +198,7 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
} }
case Permission.AlbumShare: { case Permission.AlbumShare: {
const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); return await access.album.checkOwnerAccess(auth.user.id, ids);
const isShared = await access.album.checkSharedAlbumAccess(
auth.user.id,
setDifference(ids, isOwner),
AlbumUserRole.Editor,
);
return setUnion(isOwner, isShared);
} }
case Permission.AlbumDownload: { case Permission.AlbumDownload: {
+4 -6
View File
@@ -116,24 +116,22 @@ export function withFaces(eb: ExpressionBuilder<DB, 'asset'>, withHidden?: boole
).as('faces'); ).as('faces');
} }
export function withFiles(eb: ExpressionBuilder<DB, 'asset'>, type?: AssetFileType | AssetFileType[]) { export function withFiles(eb: ExpressionBuilder<DB, 'asset'>, type?: AssetFileType) {
return jsonArrayFrom( return jsonArrayFrom(
eb eb
.selectFrom('asset_file') .selectFrom('asset_file')
.select(columns.assetFiles) .select(columns.assetFiles)
.whereRef('asset_file.assetId', '=', 'asset.id') .whereRef('asset_file.assetId', '=', 'asset.id')
.$if(!!type && typeof type === 'string', (qb) => qb.where('asset_file.type', '=', type!)) .$if(!!type, (qb) => qb.where('asset_file.type', '=', type!)),
.$if(!!type && Array.isArray(type), (qb) => qb.where('asset_file.type', 'in', type as AssetFileType[])),
).as('files'); ).as('files');
} }
export function withFilePath(eb: ExpressionBuilder<DB, 'asset'>, type: AssetFileType, isEdited = false) { export function withFilePath(eb: ExpressionBuilder<DB, 'asset'>, type: AssetFileType) {
return eb return eb
.selectFrom('asset_file') .selectFrom('asset_file')
.select('asset_file.path') .select('asset_file.path')
.whereRef('asset_file.assetId', '=', 'asset.id') .whereRef('asset_file.assetId', '=', 'asset.id')
.where('asset_file.type', '=', type) .where('asset_file.type', '=', type);
.where('asset_file.isEdited', '=', isEdited);
} }
export function withFacesAndPeople( export function withFacesAndPeople(
-27
View File
@@ -1,5 +1,4 @@
import { AssetFace } from 'src/database'; import { AssetFace } from 'src/database';
import { AssetEditActionItem, CropParameters } from 'src/dtos/editing.dto';
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
import { ImageDimensions } from 'src/types'; import { ImageDimensions } from 'src/types';
@@ -32,15 +31,6 @@ const scale = (box: BoundingBox, target: ImageDimensions, source?: ImageDimensio
}; };
}; };
const scaleCrop = (crop: CropParameters, target: ImageDimensions, source: ImageDimensions) => {
return {
width: Math.round((crop.width / source.width) * target.width),
height: Math.round((crop.height / source.height) * target.height),
x: Math.round((crop.x / source.width) * target.width),
y: Math.round((crop.y / source.height) * target.height),
};
};
export const checkFaceVisibility = ( export const checkFaceVisibility = (
faces: AssetFace[], faces: AssetFace[],
originalAssetDimensions: ImageDimensions, originalAssetDimensions: ImageDimensions,
@@ -115,20 +105,3 @@ export const checkOcrVisibility = (
hidden: status.filter((s) => !s.isVisible).map((s) => s.ocr), hidden: status.filter((s) => !s.isVisible).map((s) => s.ocr),
}; };
}; };
export const scaleEdits = (
edits: AssetEditActionItem[],
target: ImageDimensions,
source: ImageDimensions,
): AssetEditActionItem[] => {
return edits.map((edit) => {
if (edit.action === 'crop') {
return {
...edit,
parameters: scaleCrop(edit.parameters as CropParameters, target, source),
} as AssetEditActionItem;
}
return edit;
});
};
+12 -95
View File
@@ -1,12 +1,4 @@
import { AUDIO_ENCODER } from 'src/constants'; import { AUDIO_ENCODER } from 'src/constants';
import {
AssetEditAction,
AssetEditActionItem,
CropParameters,
MirrorAxis,
MirrorParameters,
RotateParameters,
} from 'src/dtos/editing.dto';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import { CQMode, ToneMapping, TranscodeHardwareAcceleration, TranscodeTarget, VideoCodec } from 'src/enum'; import { CQMode, ToneMapping, TranscodeHardwareAcceleration, TranscodeTarget, VideoCodec } from 'src/enum';
import { import {
@@ -96,26 +88,15 @@ export class BaseConfig implements VideoCodecSWConfig {
videoStream: VideoStreamInfo, videoStream: VideoStreamInfo,
audioStream?: AudioStreamInfo, audioStream?: AudioStreamInfo,
format?: VideoFormat, format?: VideoFormat,
edits: AssetEditActionItem[] = [],
) { ) {
const inputOptions = this.getBaseInputOptions(videoStream, format);
if (edits.length > 0) {
// turns out MOV files can have cropping metadata that ffmpeg automatically applies when decoding
// this means that the video streams dimensions can just be wrong once it hits the filter pipeline
// https://github.com/FFmpeg/FFmpeg/blob/f40fcf802472227851e0b8eeba40b9e6b3b8a3a1/libavutil/frame.h#L1021
inputOptions.push('-apply_cropping 0');
}
const options = { const options = {
inputOptions, inputOptions: this.getBaseInputOptions(videoStream, format),
outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'], outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'],
twoPass: this.eligibleForTwoPass(), twoPass: this.eligibleForTwoPass(),
progress: { frameCount: videoStream.frameCount, percentInterval: 5 }, progress: { frameCount: videoStream.frameCount, percentInterval: 5 },
} as TranscodeCommand; } as TranscodeCommand;
if ([TranscodeTarget.All, TranscodeTarget.Video].includes(target)) { if ([TranscodeTarget.All, TranscodeTarget.Video].includes(target)) {
const filters = this.getFilterOptions(videoStream, edits); const filters = this.getFilterOptions(videoStream);
if (filters.length > 0) { if (filters.length > 0) {
options.outputOptions.push(`-vf ${filters.join(',')}`); options.outputOptions.push(`-vf ${filters.join(',')}`);
} }
@@ -175,46 +156,10 @@ export class BaseConfig implements VideoCodecSWConfig {
return options; return options;
} }
getEditOptions(videoStream: VideoStreamInfo, edits: AssetEditActionItem[]) { getFilterOptions(videoStream: VideoStreamInfo) {
const options = []; const options = [];
let currentDimensions = { width: videoStream.width, height: videoStream.height }; if (this.shouldScale(videoStream)) {
options.push(`scale=${this.getScaling(videoStream)}`);
// Apply CPU edit operations before hwupload
for (const edit of edits) {
switch (edit.action) {
case AssetEditAction.Crop: {
options.push(this.getCropOperation(edit.parameters));
currentDimensions = { width: edit.parameters.width, height: edit.parameters.height };
break;
}
case AssetEditAction.Rotate: {
const rotateFilter = this.getRotateOperation(edit.parameters);
if (rotateFilter) {
options.push(rotateFilter);
if (Math.abs(edit.parameters.angle) === 90 || Math.abs(edit.parameters.angle) === 270) {
currentDimensions = { width: currentDimensions.height, height: currentDimensions.width };
}
}
break;
}
case AssetEditAction.Mirror: {
options.push(this.getMirrorOperation(edit.parameters));
break;
}
}
}
return { options, currentDimensions };
}
getFilterOptions(videoStream: VideoStreamInfo, edits: AssetEditActionItem[] = []) {
const options = [];
const { options: editOptions, currentDimensions } = this.getEditOptions(videoStream, edits);
options.push(...editOptions);
// Apply scaling based on current dimensions after edits
if (this.shouldScale(videoStream, currentDimensions)) {
options.push(`scale=${this.getScaling(videoStream, 2, currentDimensions)}`);
} }
const tonemapOptions = this.getToneMapping(videoStream); const tonemapOptions = this.getToneMapping(videoStream);
@@ -293,10 +238,9 @@ export class BaseConfig implements VideoCodecSWConfig {
return target; return target;
} }
shouldScale(videoStream: VideoStreamInfo, currentDimensions?: { width: number; height: number }) { shouldScale(videoStream: VideoStreamInfo) {
const dims = currentDimensions || { width: videoStream.width, height: videoStream.height }; const oddDimensions = videoStream.height % 2 !== 0 || videoStream.width % 2 !== 0;
const oddDimensions = dims.height % 2 !== 0 || dims.width % 2 !== 0; const largerThanTarget = Math.min(videoStream.height, videoStream.width) > this.getTargetResolution(videoStream);
const largerThanTarget = Math.min(dims.height, dims.width) > this.getTargetResolution(videoStream);
return oddDimensions || largerThanTarget; return oddDimensions || largerThanTarget;
} }
@@ -304,11 +248,9 @@ export class BaseConfig implements VideoCodecSWConfig {
return videoStream.isHDR && this.config.tonemap !== ToneMapping.Disabled; return videoStream.isHDR && this.config.tonemap !== ToneMapping.Disabled;
} }
getScaling(videoStream: VideoStreamInfo, mult = 2, currentDimensions?: { width: number; height: number }) { getScaling(videoStream: VideoStreamInfo, mult = 2) {
const dims = currentDimensions || { width: videoStream.width, height: videoStream.height };
const targetResolution = this.getTargetResolution(videoStream); const targetResolution = this.getTargetResolution(videoStream);
const isVertical = dims.height > dims.width || this.isVideoRotated(videoStream); return this.isVideoVertical(videoStream) ? `${targetResolution}:-${mult}` : `-${mult}:${targetResolution}`;
return isVertical ? `${targetResolution}:-${mult}` : `-${mult}:${targetResolution}`;
} }
getSize(videoStream: VideoStreamInfo) { getSize(videoStream: VideoStreamInfo) {
@@ -387,31 +329,6 @@ export class BaseConfig implements VideoCodecSWConfig {
useCQP() { useCQP() {
return this.config.cqMode === CQMode.Cqp; return this.config.cqMode === CQMode.Cqp;
} }
// Edit operations (software filters)
getCropOperation({ x, y, width, height }: CropParameters): string {
return `crop=${width}:${height}:${x}:${y}`;
}
getRotateOperation({ angle }: RotateParameters): string {
switch (angle) {
case 90: {
return 'transpose=1'; // 90° clockwise
}
case 180: {
return 'hflip,vflip'; // 180°
}
case 270: {
return 'transpose=2'; // 90° counter-clockwise (270° clockwise)
}
}
return '';
}
getMirrorOperation({ axis }: MirrorParameters): string {
return axis === MirrorAxis.Horizontal ? 'hflip' : 'vflip';
}
} }
export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig { export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig {
@@ -506,14 +423,14 @@ export class ThumbnailConfig extends BaseConfig {
return ['-fps_mode vfr', '-frames:v 1', '-update 1']; return ['-fps_mode vfr', '-frames:v 1', '-update 1'];
} }
getFilterOptions(videoStream: VideoStreamInfo, edits: AssetEditActionItem[] = []): string[] { getFilterOptions(videoStream: VideoStreamInfo): string[] {
return [ return [
'fps=12:start_time=0:eof_action=pass:round=down', 'fps=12:start_time=0:eof_action=pass:round=down',
'thumbnail=12', 'thumbnail=12',
String.raw`select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20)`, String.raw`select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20)`,
'trim=end_frame=2', 'trim=end_frame=2',
'reverse', 'reverse',
...super.getFilterOptions(videoStream, edits), ...super.getFilterOptions(videoStream),
]; ];
} }
+4
View File
@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}
+6 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-web", "name": "immich-web",
"version": "2.6.2", "version": "2.6.1",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -72,10 +72,10 @@
"@koddsson/eslint-plugin-tscompat": "^0.2.0", "@koddsson/eslint-plugin-tscompat": "^0.2.0",
"@socket.io/component-emitter": "^3.1.0", "@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.8", "@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/enhanced-img": "^0.10.4", "@sveltejs/enhanced-img": "^0.10.0",
"@sveltejs/kit": "^2.27.1", "@sveltejs/kit": "^2.27.1",
"@sveltejs/vite-plugin-svelte": "7.0.0", "@sveltejs/vite-plugin-svelte": "6.2.4",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.1.7",
"@testing-library/jest-dom": "^6.4.2", "@testing-library/jest-dom": "^6.4.2",
"@testing-library/svelte": "^5.2.8", "@testing-library/svelte": "^5.2.8",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
@@ -103,10 +103,10 @@
"svelte": "5.53.13", "svelte": "5.53.13",
"svelte-check": "^4.1.5", "svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.3.3", "svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.1.7",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.45.0", "typescript-eslint": "^8.45.0",
"vite": "^8.0.0", "vite": "^7.1.2",
"vitest": "^4.0.0" "vitest": "^4.0.0"
}, },
"volta": { "volta": {
+171 -37
View File
@@ -1,59 +1,193 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import type { ZoomImageWheelState } from '@zoom-image/core';
import { createZoomImageWheel } from '@zoom-image/core'; import { createZoomImageWheel } from '@zoom-image/core';
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => { // Minimal touch shape — avoids importing DOM TouchEvent which isn't available in all TS targets.
const zoomInstance = createZoomImageWheel(node, { type TouchEventLike = {
maxZoom: 10, touches: Iterable<{ clientX: number; clientY: number }> & { length: number };
targetTouches: ArrayLike<unknown>;
};
const asTouchEvent = (event: Event) => event as unknown as TouchEventLike;
export const MAX_ZOOM = 10;
export const zoomImageAction = (node: HTMLElement, options?: { zoomTarget?: HTMLElement }) => {
let zoomInstance = createZoomImageWheel(node, {
maxZoom: MAX_ZOOM,
initialState: assetViewerManager.zoomState, initialState: assetViewerManager.zoomState,
zoomTarget: null, zoomTarget: options?.zoomTarget,
}); });
const unsubscribes = [ let needsResync = false;
assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }),
zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)),
];
const onInteractionStart = (event: Event) => { const createInstance = () => {
if (options?.disabled) { zoomInstance.cleanup();
event.stopImmediatePropagation(); zoomInstance = createZoomImageWheel(node, {
} maxZoom: MAX_ZOOM,
assetViewerManager.cancelZoomAnimation(); initialState: { ...assetViewerManager.zoomState, enable: true },
zoomTarget: options?.zoomTarget,
});
node.style.overflow = 'visible';
unsubscribeStore?.();
unsubscribeStore = zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state));
needsResync = false;
}; };
node.addEventListener('wheel', onInteractionStart, { capture: true }); const applyDirectTransform = (state: ZoomImageWheelState) => {
node.addEventListener('pointerdown', onInteractionStart, { capture: true }); const target = options?.zoomTarget ?? node.querySelector('img');
if (target) {
(target as HTMLElement).style.transformOrigin = '0 0';
(target as HTMLElement).style.transform =
`translate(${state.currentPositionX}px, ${state.currentPositionY}px) scale(${state.currentZoom})`;
needsResync = true;
}
};
// Suppress Safari's synthetic dblclick on double-tap. Without this, zoom-image's touchstart const resyncIfNeeded = () => {
// handler zooms to maxZoom (10x), then Safari's synthetic dblclick triggers photo-viewer's if (needsResync) {
// handler which conflicts. Chrome does not fire synthetic dblclick on touch. createInstance();
}
};
let unsubscribeStore = zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state));
const unsubscribeManager = assetViewerManager.on({
ZoomChange: (state) => zoomInstance.setState(state),
DirectTransform: (state) => applyDirectTransform(state),
ZoomEnabled: (enabled) => {
if (enabled && needsResync) {
createInstance();
} else {
zoomInstance.setState({ enable: enabled });
}
},
});
const controller = new AbortController();
const { signal } = controller;
node.addEventListener('pointerdown', () => assetViewerManager.cancelZoomAnimation(), { capture: true, signal });
node.addEventListener('pointerdown', resyncIfNeeded, { signal });
node.addEventListener('wheel', resyncIfNeeded, { signal });
// Intercept events in capture phase to prevent zoom-image from seeing interactions on
// overlay elements (e.g. OCR text boxes), preserving browser defaults like text selection.
const isOverlayEvent = (event: Event) => !!(event.target as HTMLElement).closest('[data-overlay-interactive]');
const isOverlayAtPoint = (x: number, y: number) =>
!!document.elementFromPoint(x, y)?.closest('[data-overlay-interactive]');
// Pointer event interception: track pointers that start on overlays and intercept the entire gesture.
const overlayPointers = new Set<number>();
const interceptedPointers = new Set<number>();
const interceptOverlayPointerDown = (event: PointerEvent) => {
if (isOverlayEvent(event) || isOverlayAtPoint(event.clientX, event.clientY)) {
overlayPointers.add(event.pointerId);
interceptedPointers.add(event.pointerId);
event.stopPropagation();
} else if (overlayPointers.size > 0) {
// Split gesture (e.g. pinch with one finger on overlay) — intercept entirely.
interceptedPointers.add(event.pointerId);
event.stopPropagation();
}
};
const interceptOverlayPointerEvent = (event: PointerEvent) => {
if (interceptedPointers.has(event.pointerId)) {
event.stopPropagation();
}
};
const interceptOverlayPointerEnd = (event: PointerEvent) => {
overlayPointers.delete(event.pointerId);
if (interceptedPointers.delete(event.pointerId)) {
event.stopPropagation();
}
};
node.addEventListener('pointerdown', interceptOverlayPointerDown, { capture: true, signal });
node.addEventListener('pointermove', interceptOverlayPointerEvent, { capture: true, signal });
node.addEventListener('pointerup', interceptOverlayPointerEnd, { capture: true, signal });
node.addEventListener('pointerleave', interceptOverlayPointerEnd, { capture: true, signal });
// Touch event interception for overlay touches or split gestures (pinch across container boundary).
// Once intercepted, stays intercepted until all fingers are lifted.
let touchGestureIntercepted = false;
const interceptOverlayTouchEvent = (event: Event) => {
if (touchGestureIntercepted) {
event.stopPropagation();
return;
}
const { touches, targetTouches } = asTouchEvent(event);
if (touches && targetTouches) {
if (touches.length > targetTouches.length) {
touchGestureIntercepted = true;
event.stopPropagation();
return;
}
for (const touch of touches) {
if (isOverlayAtPoint(touch.clientX, touch.clientY)) {
touchGestureIntercepted = true;
event.stopPropagation();
return;
}
}
} else if (isOverlayEvent(event)) {
event.stopPropagation();
}
};
const resetTouchGesture = (event: Event) => {
const { touches } = asTouchEvent(event);
if (touches.length === 0) {
touchGestureIntercepted = false;
}
};
node.addEventListener('touchstart', interceptOverlayTouchEvent, { capture: true, signal });
node.addEventListener('touchmove', interceptOverlayTouchEvent, { capture: true, signal });
node.addEventListener('touchend', resetTouchGesture, { capture: true, signal });
// Wheel and dblclick interception on overlay elements.
// Dblclick also intercepted for all touch double-taps (Safari fires synthetic dblclick
// on double-tap, which conflicts with zoom-image's touch zoom handler).
let lastPointerWasTouch = false; let lastPointerWasTouch = false;
const trackPointerType = (event: PointerEvent) => { node.addEventListener('pointerdown', (event) => (lastPointerWasTouch = event.pointerType === 'touch'), {
lastPointerWasTouch = event.pointerType === 'touch'; capture: true,
}; signal,
const suppressTouchDblClick = (event: MouseEvent) => { });
if (lastPointerWasTouch) { node.addEventListener(
event.stopImmediatePropagation(); 'wheel',
} (event) => {
}; if (isOverlayEvent(event)) {
node.addEventListener('pointerdown', trackPointerType, { capture: true }); event.stopPropagation();
node.addEventListener('dblclick', suppressTouchDblClick, { capture: true }); }
},
{ capture: true, signal },
);
node.addEventListener(
'dblclick',
(event) => {
if (lastPointerWasTouch || isOverlayEvent(event)) {
event.stopImmediatePropagation();
}
},
{ capture: true, signal },
);
// Allow zoomed content to render outside the container bounds if (options?.zoomTarget) {
options.zoomTarget.style.willChange = 'transform';
}
node.style.overflow = 'visible'; node.style.overflow = 'visible';
// Prevent browser handling of touch gestures so zoom-image can manage them
node.style.touchAction = 'none'; node.style.touchAction = 'none';
return { return {
update(newOptions?: { disabled?: boolean }) { update(newOptions?: { zoomTarget?: HTMLElement }) {
options = newOptions; options = newOptions;
if (newOptions?.zoomTarget !== undefined) {
zoomInstance.setState({ zoomTarget: newOptions.zoomTarget });
}
}, },
destroy() { destroy() {
for (const unsubscribe of unsubscribes) { controller.abort();
unsubscribe(); if (options?.zoomTarget) {
options.zoomTarget.style.willChange = '';
} }
node.removeEventListener('wheel', onInteractionStart, { capture: true }); unsubscribeManager();
node.removeEventListener('pointerdown', onInteractionStart, { capture: true }); unsubscribeStore?.();
node.removeEventListener('pointerdown', trackPointerType, { capture: true });
node.removeEventListener('dblclick', suppressTouchDblClick, { capture: true });
zoomInstance.cleanup(); zoomInstance.cleanup();
}, },
}; };
+72 -72
View File
@@ -7,7 +7,7 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { getAssetUrls } from '$lib/utils'; import { getAssetUrls } from '$lib/utils';
import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte'; import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte';
import { scaleToCover, scaleToFit } from '$lib/utils/container-utils'; import { scaleToCover, scaleToFit, type Size } from '$lib/utils/container-utils';
import { getAltText } from '$lib/utils/thumbnail-util'; import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk'; import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
@@ -17,15 +17,14 @@
asset: AssetResponseDto; asset: AssetResponseDto;
sharedLink?: SharedLinkResponseDto; sharedLink?: SharedLinkResponseDto;
objectFit?: 'contain' | 'cover'; objectFit?: 'contain' | 'cover';
container: { container: Size;
width: number;
height: number;
};
onUrlChange?: (url: string) => void; onUrlChange?: (url: string) => void;
onImageReady?: () => void; onImageReady?: () => void;
onError?: () => void; onError?: () => void;
ref?: HTMLDivElement; ref?: HTMLDivElement;
imgRef?: HTMLImageElement; imgRef?: HTMLImageElement;
imgNaturalSize?: Size;
imgScaledSize?: Size;
backdrop?: Snippet; backdrop?: Snippet;
overlays?: Snippet; overlays?: Snippet;
}; };
@@ -34,6 +33,10 @@
ref = $bindable(), ref = $bindable(),
// eslint-disable-next-line no-useless-assignment // eslint-disable-next-line no-useless-assignment
imgRef = $bindable(), imgRef = $bindable(),
// eslint-disable-next-line no-useless-assignment
imgNaturalSize = $bindable(),
// eslint-disable-next-line no-useless-assignment
imgScaledSize = $bindable(),
asset, asset,
sharedLink, sharedLink,
objectFit = 'contain', objectFit = 'contain',
@@ -101,9 +104,21 @@
return { width: 1, height: 1 }; return { width: 1, height: 1 };
}); });
const { width, height, left, top } = $derived.by(() => { $effect(() => {
imgNaturalSize = imageDimensions;
});
const scaledDimensions = $derived.by(() => {
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit; const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
const { width, height } = scaleFn(imageDimensions, container); return scaleFn(imageDimensions, container);
});
$effect(() => {
imgScaledSize = scaledDimensions;
});
const { width, height, left, top } = $derived.by(() => {
const { width, height } = scaledDimensions;
return { return {
width: width + 'px', width: width + 'px',
height: height + 'px', height: height + 'px',
@@ -149,81 +164,66 @@
(quality.preview === 'success' ? previewElement : undefined) ?? (quality.preview === 'success' ? previewElement : undefined) ??
(quality.thumbnail === 'success' ? thumbnailElement : undefined); (quality.thumbnail === 'success' ? thumbnailElement : undefined);
}); });
const zoomTransform = $derived.by(() => {
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
if (currentZoom === 1 && currentPositionX === 0 && currentPositionY === 0) {
return undefined;
}
return `translate(${currentPositionX}px, ${currentPositionY}px) scale(${currentZoom})`;
});
</script> </script>
<div class="relative h-full w-full overflow-hidden will-change-transform" bind:this={ref}> <div class="relative h-full w-full overflow-hidden will-change-transform" bind:this={ref}>
{@render backdrop?.()} {@render backdrop?.()}
<!-- pointer-events-none so events pass through to the container where zoom-image listens --> <div class="absolute inset-0 pointer-events-none" style:left style:top style:width style:height>
<div {#if show.alphaBackground}
class="absolute inset-0 pointer-events-none" <AlphaBackground />
style:transform={zoomTransform} {/if}
style:transform-origin={zoomTransform ? '0 0' : undefined}
>
<div class="absolute" style:left style:top style:width style:height>
{#if show.alphaBackground}
<AlphaBackground />
{/if}
{#if show.thumbhash} {#if show.thumbhash}
{#if asset.thumbhash} {#if asset.thumbhash}
<!-- Thumbhash / spinner layer --> <!-- Thumbhash / spinner layer -->
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute"></canvas> <canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute"></canvas>
{:else if show.spinner} {:else if show.spinner}
<DelayedLoadingSpinner /> <DelayedLoadingSpinner />
{/if}
{/if} {/if}
{/if}
{#if show.thumbnail} {#if show.thumbnail}
<ImageLayer <ImageLayer
{adaptiveImageLoader} {adaptiveImageLoader}
{width} {width}
{height} {height}
quality="thumbnail" quality="thumbnail"
src={status.urls.thumbnail} src={status.urls.thumbnail}
alt="" alt=""
role="presentation" role="presentation"
bind:ref={thumbnailElement} bind:ref={thumbnailElement}
/> />
{/if} {/if}
{#if show.brokenAsset} {#if show.brokenAsset}
<BrokenAsset class="text-xl h-full w-full absolute" /> <BrokenAsset class="text-xl h-full w-full absolute" />
{/if} {/if}
{#if show.preview} {#if show.preview}
<ImageLayer <ImageLayer
{adaptiveImageLoader} {adaptiveImageLoader}
{alt} {alt}
{width} {width}
{height} {height}
{overlays} {overlays}
quality="preview" quality="preview"
src={status.urls.preview} src={status.urls.preview}
bind:ref={previewElement} bind:ref={previewElement}
/> />
{/if} {/if}
{#if show.original} {#if show.original}
<ImageLayer <ImageLayer
{adaptiveImageLoader} {adaptiveImageLoader}
{alt} {alt}
{width} {width}
{height} {height}
{overlays} {overlays}
quality="original" quality="original"
src={status.urls.original} src={status.urls.original}
bind:ref={originalElement} bind:ref={originalElement}
/> />
{/if} {/if}
</div>
</div> </div>
</div> </div>
@@ -59,7 +59,7 @@
previousAsset?: AssetResponseDto; previousAsset?: AssetResponseDto;
}; };
interface Props { type Props = {
cursor: AssetCursor; cursor: AssetCursor;
showNavigation?: boolean; showNavigation?: boolean;
withStacked?: boolean; withStacked?: boolean;
@@ -72,7 +72,7 @@
onUndoDelete?: OnUndoDelete; onUndoDelete?: OnUndoDelete;
onClose?: (asset: AssetResponseDto) => void; onClose?: (asset: AssetResponseDto) => void;
onRandom?: () => Promise<{ id: string } | undefined>; onRandom?: () => Promise<{ id: string } | undefined>;
} };
let { let {
cursor, cursor,
@@ -176,6 +176,7 @@
onDestroy(() => { onDestroy(() => {
activityManager.reset(); activityManager.reset();
assetViewerManager.closeEditor(); assetViewerManager.closeEditor();
isFaceEditMode.value = false;
syncAssetViewerOpenClass(false); syncAssetViewerOpenClass(false);
preloadManager.destroy(); preloadManager.destroy();
}); });
@@ -290,6 +291,9 @@
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => { const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
previewStackedAsset = isMouseOver ? stackedAsset : undefined; previewStackedAsset = isMouseOver ? stackedAsset : undefined;
if (isMouseOver) {
isFaceEditMode.value = false;
}
}; };
const handlePreAction = (action: Action) => { const handlePreAction = (action: Action) => {
@@ -358,15 +362,18 @@
} }
}; };
const refreshOcr = async () => {
ocrManager.clear();
if (sharedLink) {
return;
}
await ocrManager.getAssetOcr(asset.id);
};
const refresh = async () => { const refresh = async () => {
await refreshStack(); await refreshStack();
ocrManager.clear(); await refreshOcr();
if (!sharedLink) {
if (previewStackedAsset) {
await ocrManager.getAssetOcr(previewStackedAsset.id);
}
await ocrManager.getAssetOcr(asset.id);
}
}; };
$effect(() => { $effect(() => {
@@ -375,6 +382,12 @@
untrack(() => handlePromiseError(refresh())); untrack(() => handlePromiseError(refresh()));
}); });
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
previewStackedAsset;
untrack(() => ocrManager.clear());
});
let lastCursor = $state<AssetCursor>(); let lastCursor = $state<AssetCursor>();
$effect(() => { $effect(() => {
@@ -460,7 +473,7 @@
<section <section
id="immich-asset-viewer" id="immich-asset-viewer"
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black" class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black touch-none"
use:focusTrap use:focusTrap
bind:this={assetViewerHtmlElement} bind:this={assetViewerHtmlElement}
> >
@@ -612,6 +625,7 @@
onClick={() => { onClick={() => {
cursor.current = stackedAsset; cursor.current = stackedAsset;
previewStackedAsset = undefined; previewStackedAsset = undefined;
isFaceEditMode.value = false;
}} }}
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)} onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
readonly readonly
@@ -7,10 +7,11 @@
import { timeToLoadTheMap } from '$lib/constants'; import { timeToLoadTheMap } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte'; import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { isEditFacesPanelOpen, isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store'; import { boundingBoxesArray } from '$lib/stores/people.store';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { preferences, user } from '$lib/stores/user.store'; import { preferences, user } from '$lib/stores/user.store';
@@ -49,15 +50,15 @@
import UserAvatar from '../shared-components/user-avatar.svelte'; import UserAvatar from '../shared-components/user-avatar.svelte';
import AlbumListItemDetails from './album-list-item-details.svelte'; import AlbumListItemDetails from './album-list-item-details.svelte';
interface Props { type Props = {
asset: AssetResponseDto; asset: AssetResponseDto;
currentAlbum?: AlbumResponseDto | null; currentAlbum?: AlbumResponseDto | null;
} };
let { asset, currentAlbum = null }: Props = $props(); let { asset, currentAlbum = null }: Props = $props();
let showAssetPath = $state(false); let showAssetPath = $state(false);
let showEditFaces = $state(false); let showEditFaces = $derived(isEditFacesPanelOpen.value);
let isOwner = $derived($user?.id === asset.ownerId); let isOwner = $derived($user?.id === asset.ownerId);
let people = $derived(asset.people || []); let people = $derived(asset.people || []);
let unassignedFaces = $derived(asset.unassignedFaces || []); let unassignedFaces = $derived(asset.unassignedFaces || []);
@@ -106,7 +107,7 @@
return; return;
} }
showEditFaces = false; isEditFacesPanelOpen.value = false;
previousId = asset.id; previousId = asset.id;
}); });
@@ -122,7 +123,8 @@
const handleRefreshPeople = async () => { const handleRefreshPeople = async () => {
asset = await getAssetInfo({ id: asset.id }); asset = await getAssetInfo({ id: asset.id });
showEditFaces = false; eventManager.emit('AssetUpdate', asset);
isEditFacesPanelOpen.value = false;
}; };
const getAssetFolderHref = (asset: AssetResponseDto) => { const getAssetFolderHref = (asset: AssetResponseDto) => {
@@ -219,7 +221,7 @@
shape="round" shape="round"
color="secondary" color="secondary"
variant="ghost" variant="ghost"
onclick={() => (showEditFaces = true)} onclick={() => (isEditFacesPanelOpen.value = true)}
/> />
{/if} {/if}
</div> </div>
@@ -228,13 +230,14 @@
<div class="mt-2 flex flex-wrap gap-2"> <div class="mt-2 flex flex-wrap gap-2">
{#each people as person, index (person.id)} {#each people as person, index (person.id)}
{#if showingHiddenPeople || !person.isHidden} {#if showingHiddenPeople || !person.isHidden}
{@const isHighlighted = people[index].faces.some((f) => $boundingBoxesArray.some((b) => b.id === f.id))}
<a <a
class="w-22" class="group w-22 outline-none"
href={Route.viewPerson(person, { previousRoute })} href={Route.viewPerson(person, { previousRoute })}
onfocus={() => ($boundingBoxesArray = people[index].faces)} onfocus={() => ($boundingBoxesArray = people[index].faces)}
onblur={() => ($boundingBoxesArray = [])} onblur={() => ($boundingBoxesArray = [])}
onmouseover={() => ($boundingBoxesArray = people[index].faces)} onpointerover={() => ($boundingBoxesArray = people[index].faces)}
onmouseleave={() => ($boundingBoxesArray = [])} onpointerleave={() => ($boundingBoxesArray = [])}
> >
<div class="relative"> <div class="relative">
<ImageThumbnail <ImageThumbnail
@@ -246,6 +249,8 @@
widthStyle="90px" widthStyle="90px"
heightStyle="90px" heightStyle="90px"
hidden={person.isHidden} hidden={person.isHidden}
highlighted={isHighlighted}
class="group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-immich-primary dark:group-focus-visible:outline-immich-dark-primary"
/> />
</div> </div>
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p> <p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
@@ -574,7 +579,7 @@
<PersonSidePanel <PersonSidePanel
assetId={asset.id} assetId={asset.id}
assetType={asset.type} assetType={asset.type}
onClose={() => (showEditFaces = false)} onClose={() => (isEditFacesPanelOpen.value = false)}
onRefresh={handleRefreshPeople} onRefresh={handleRefreshPeople}
/> />
{/if} {/if}
@@ -1,10 +1,12 @@
<script lang="ts"> <script lang="ts">
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte'; import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils'; import { getPeopleThumbnailUrl } from '$lib/utils';
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils'; import { computeContentMetrics, mapContentRectToNatural, type Size } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { scaleFaceRectOnResize, type ResizeContext } from '$lib/utils/people-utils';
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk'; import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { shortcut } from '$lib/actions/shortcut'; import { shortcut } from '$lib/actions/shortcut';
import { Button, Input, modalManager, toastManager } from '@immich/ui'; import { Button, Input, modalManager, toastManager } from '@immich/ui';
@@ -12,17 +14,19 @@
import { clamp } from 'lodash-es'; import { clamp } from 'lodash-es';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props { type Props = {
htmlElement: HTMLImageElement | HTMLVideoElement; imageSize: Size;
containerWidth: number; containerWidth: number;
containerHeight: number; containerHeight: number;
assetId: string; assetId: string;
} };
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props(); let { imageSize, containerWidth, containerHeight, assetId }: Props = $props();
let canvasEl: HTMLCanvasElement | undefined = $state(); let canvasEl: HTMLCanvasElement | undefined = $state();
let containerEl: HTMLDivElement | undefined = $state();
let canvas: Canvas | undefined = $state(); let canvas: Canvas | undefined = $state();
let faceRect: Rect | undefined = $state(); let faceRect: Rect | undefined = $state();
let faceSelectorEl: HTMLDivElement | undefined = $state(); let faceSelectorEl: HTMLDivElement | undefined = $state();
@@ -32,6 +36,9 @@
let searchTerm = $state(''); let searchTerm = $state('');
let faceBoxPosition = $state({ left: 0, top: 0, width: 0, height: 0 }); let faceBoxPosition = $state({ left: 0, top: 0, width: 0, height: 0 });
let userMovedRect = false;
let previousMetrics: ResizeContext | null = null;
let panModifierHeld = $state(false);
let filteredCandidates = $derived( let filteredCandidates = $derived(
searchTerm searchTerm
@@ -53,11 +60,12 @@
}; };
const setupCanvas = () => { const setupCanvas = () => {
if (!canvasEl || !htmlElement) { if (!canvasEl) {
return; return;
} }
canvas = new Canvas(canvasEl); canvas = new Canvas(canvasEl, { width: containerWidth, height: containerHeight });
canvas.selection = false;
configureControlStyle(); configureControlStyle();
// eslint-disable-next-line tscompat/tscompat // eslint-disable-next-line tscompat/tscompat
@@ -75,66 +83,103 @@
canvas.add(faceRect); canvas.add(faceRect);
canvas.setActiveObject(faceRect); canvas.setActiveObject(faceRect);
setDefaultFaceRectanglePosition(faceRect);
}; };
onMount(async () => { onMount(() => {
setupCanvas(); void getPeople();
await getPeople();
}); });
const imageContentMetrics = $derived.by(() => {
const natural = getNaturalSize(htmlElement);
const container = { width: containerWidth, height: containerHeight };
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, container);
return {
contentWidth,
contentHeight,
offsetX: (containerWidth - contentWidth) / 2,
offsetY: (containerHeight - contentHeight) / 2,
};
});
const setDefaultFaceRectanglePosition = (faceRect: Rect) => {
const { offsetX, offsetY } = imageContentMetrics;
faceRect.set({
top: offsetY + 200,
left: offsetX + 200,
});
faceRect.setCoords();
positionFaceSelector();
};
$effect(() => { $effect(() => {
if (!canvas) { if (!canvas) {
return; return;
} }
canvas.setDimensions({ const upperCanvas = canvas.upperCanvasEl;
width: containerWidth, const controller = new AbortController();
height: containerHeight, const { signal } = controller;
});
if (!faceRect) { const stopIfOnTarget = (event: PointerEvent) => {
if (canvas?.findTarget(event).target) {
event.stopPropagation();
}
};
const handlePointerDown = (event: PointerEvent) => {
if (!canvas) {
return;
}
if (canvas.findTarget(event).target) {
event.stopPropagation();
return;
}
if (faceRect) {
event.stopPropagation();
const pointer = canvas.getScenePoint(event);
faceRect.set({ left: pointer.x, top: pointer.y });
faceRect.setCoords();
userMovedRect = true;
canvas.renderAll();
positionFaceSelector();
}
};
upperCanvas.addEventListener('pointerdown', handlePointerDown, { signal });
upperCanvas.addEventListener('pointermove', stopIfOnTarget, { signal });
upperCanvas.addEventListener('pointerup', stopIfOnTarget, { signal });
return () => {
controller.abort();
};
});
const imageContentMetrics = $derived.by(() => {
if (imageSize.width === 0 || imageSize.height === 0) {
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
}
return computeContentMetrics(imageSize, { width: containerWidth, height: containerHeight });
});
const setDefaultFaceRectanglePosition = (faceRect: Rect) => {
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
faceRect.set({
top: offsetY + contentHeight / 2 - 56,
left: offsetX + contentWidth / 2 - 56,
});
};
$effect(() => {
const { offsetX, offsetY, contentWidth } = imageContentMetrics;
if (contentWidth === 0) {
return; return;
} }
if (!isFaceRectIntersectingCanvas(faceRect, canvas)) { const isFirstRun = previousMetrics === null;
if (isFirstRun && !canvas) {
setupCanvas();
}
if (!canvas || !faceRect) {
return;
}
if (!isFirstRun) {
canvas.setDimensions({ width: containerWidth, height: containerHeight });
}
if (!isFirstRun && userMovedRect && previousMetrics) {
faceRect.set(scaleFaceRectOnResize(faceRect, previousMetrics, { contentWidth, offsetX, offsetY }));
} else {
setDefaultFaceRectanglePosition(faceRect); setDefaultFaceRectanglePosition(faceRect);
} }
});
const isFaceRectIntersectingCanvas = (faceRect: Rect, canvas: Canvas) => { faceRect.setCoords();
const faceBox = faceRect.getBoundingRect(); previousMetrics = { contentWidth, offsetX, offsetY };
return !( canvas.renderAll();
0 > faceBox.left + faceBox.width || positionFaceSelector();
0 > faceBox.top + faceBox.height || });
canvas.width < faceBox.left ||
canvas.height < faceBox.top
);
};
const cancel = () => { const cancel = () => {
isFaceEditMode.value = false; isFaceEditMode.value = false;
@@ -164,11 +209,15 @@
const gap = 15; const gap = 15;
const padding = faceRect.padding ?? 0; const padding = faceRect.padding ?? 0;
const rawBox = faceRect.getBoundingRect(); const rawBox = faceRect.getBoundingRect();
if (Number.isNaN(rawBox.left) || Number.isNaN(rawBox.width)) {
return;
}
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
const faceBox = { const faceBox = {
left: rawBox.left - padding, left: (rawBox.left - padding) * currentZoom + currentPositionX,
top: rawBox.top - padding, top: (rawBox.top - padding) * currentZoom + currentPositionY,
width: rawBox.width + padding * 2, width: (rawBox.width + padding * 2) * currentZoom,
height: rawBox.height + padding * 2, height: (rawBox.height + padding * 2) * currentZoom,
}; };
const selectorWidth = faceSelectorEl.offsetWidth; const selectorWidth = faceSelectorEl.offsetWidth;
const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight; const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight;
@@ -178,20 +227,21 @@
const clampTop = (top: number) => clamp(top, gap, containerHeight - selectorHeight - gap); const clampTop = (top: number) => clamp(top, gap, containerHeight - selectorHeight - gap);
const clampLeft = (left: number) => clamp(left, gap, containerWidth - selectorWidth - gap); const clampLeft = (left: number) => clamp(left, gap, containerWidth - selectorWidth - gap);
const overlapArea = (position: { top: number; left: number }) => { const faceRight = faceBox.left + faceBox.width;
const selectorRight = position.left + selectorWidth; const faceBottom = faceBox.top + faceBox.height;
const selectorBottom = position.top + selectorHeight;
const faceRight = faceBox.left + faceBox.width;
const faceBottom = faceBox.top + faceBox.height;
const overlapX = Math.max(0, Math.min(selectorRight, faceRight) - Math.max(position.left, faceBox.left)); const overlapArea = (position: { top: number; left: number }) => {
const overlapY = Math.max(0, Math.min(selectorBottom, faceBottom) - Math.max(position.top, faceBox.top)); const overlapX = Math.max(
0,
Math.min(position.left + selectorWidth, faceRight) - Math.max(position.left, faceBox.left),
);
const overlapY = Math.max(
0,
Math.min(position.top + selectorHeight, faceBottom) - Math.max(position.top, faceBox.top),
);
return overlapX * overlapY; return overlapX * overlapY;
}; };
const faceBottom = faceBox.top + faceBox.height;
const faceRight = faceBox.left + faceBox.width;
const positions = [ const positions = [
{ top: clampTop(faceBottom + gap), left: clampLeft(faceBox.left) }, { top: clampTop(faceBottom + gap), left: clampLeft(faceBox.left) },
{ top: clampTop(faceBox.top - selectorHeight - gap), left: clampLeft(faceBox.left) }, { top: clampTop(faceBox.top - selectorHeight - gap), left: clampLeft(faceBox.left) },
@@ -213,45 +263,139 @@
} }
} }
faceSelectorEl.style.top = `${bestPosition.top}px`; const containerRect = containerEl?.getBoundingClientRect();
faceSelectorEl.style.left = `${bestPosition.left}px`; const offsetTop = containerRect?.top ?? 0;
const offsetLeft = containerRect?.left ?? 0;
faceSelectorEl.style.top = `${bestPosition.top + offsetTop}px`;
faceSelectorEl.style.left = `${bestPosition.left + offsetLeft}px`;
scrollableListEl.style.height = `${listHeight}px`; scrollableListEl.style.height = `${listHeight}px`;
faceBoxPosition = { left: faceBox.left, top: faceBox.top, width: faceBox.width, height: faceBox.height }; faceBoxPosition = faceBox;
}; };
$effect(() => {
if (!canvas) {
return;
}
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
canvas.setViewportTransform([currentZoom, 0, 0, currentZoom, currentPositionX, currentPositionY]);
canvas.renderAll();
positionFaceSelector();
});
$effect(() => { $effect(() => {
const rect = faceRect; const rect = faceRect;
if (rect) { if (rect) {
rect.on('moving', positionFaceSelector); const onUserMove = () => {
rect.on('scaling', positionFaceSelector); userMovedRect = true;
positionFaceSelector();
};
rect.on('moving', onUserMove);
rect.on('scaling', onUserMove);
return () => { return () => {
rect.off('moving', positionFaceSelector); rect.off('moving', onUserMove);
rect.off('scaling', positionFaceSelector); rect.off('scaling', onUserMove);
}; };
} }
}); });
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
const panModifierKey = isMac ? 'Meta' : 'Control';
const panModifierLabel = isMac ? '⌘' : 'Ctrl';
const isZoomed = $derived(assetViewerManager.zoom > 1);
$effect(() => {
if (!containerEl) {
return;
}
const element = containerEl;
const parent = element.parentElement;
const activate = () => {
panModifierHeld = true;
element.style.pointerEvents = 'none';
if (parent) {
parent.style.cursor = 'move';
}
};
const deactivate = () => {
panModifierHeld = false;
element.style.pointerEvents = '';
if (parent) {
parent.style.cursor = '';
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === panModifierKey) {
activate();
}
};
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === panModifierKey) {
deactivate();
}
};
document.addEventListener('keydown', onKeyDown);
document.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', deactivate);
return () => {
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', deactivate);
deactivate();
};
});
const trapEvents = (node: HTMLElement) => {
const stop = (e: Event) => e.stopPropagation();
const eventTypes = ['keydown', 'pointerdown', 'pointermove', 'pointerup'] as const;
for (const type of eventTypes) {
node.addEventListener(type, stop);
}
// Move to body so the selector isn't affected by the zoom transform on the container
document.body.append(node);
return {
destroy() {
for (const type of eventTypes) {
node.removeEventListener(type, stop);
}
node.remove();
},
};
};
const getFaceCroppedCoordinates = () => { const getFaceCroppedCoordinates = () => {
if (!faceRect || !htmlElement) { if (!faceRect || imageSize.width === 0 || imageSize.height === 0) {
return; return;
} }
const { left, top, width, height } = faceRect.getBoundingRect(); const scaledWidth = faceRect.getScaledWidth();
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics; const scaledHeight = faceRect.getScaledHeight();
const natural = getNaturalSize(htmlElement);
const scaleX = natural.width / contentWidth; const imageRect = mapContentRectToNatural(
const scaleY = natural.height / contentHeight; {
const imageX = (left - offsetX) * scaleX; left: faceRect.left - scaledWidth / 2,
const imageY = (top - offsetY) * scaleY; top: faceRect.top - scaledHeight / 2,
width: scaledWidth,
height: scaledHeight,
},
imageContentMetrics,
imageSize,
);
return { return {
imageWidth: natural.width, imageWidth: imageSize.width,
imageHeight: natural.height, imageHeight: imageSize.height,
x: Math.floor(imageX), x: Math.floor(imageRect.left),
y: Math.floor(imageY), y: Math.floor(imageRect.top),
width: Math.floor(width * scaleX), width: Math.floor(imageRect.width),
height: Math.floor(height * scaleY), height: Math.floor(imageRect.height),
}; };
}; };
@@ -282,10 +426,9 @@
}); });
await assetViewingStore.setAssetId(assetId); await assetViewingStore.setAssetId(assetId);
isFaceEditMode.value = false;
} catch (error) { } catch (error) {
handleError(error, 'Error tagging face'); handleError(error, 'Error tagging face');
} finally {
isFaceEditMode.value = false;
} }
}; };
</script> </script>
@@ -294,6 +437,7 @@
<div <div
id="face-editor-data" id="face-editor-data"
bind:this={containerEl}
class="absolute start-0 top-0 z-5 h-full w-full overflow-hidden" class="absolute start-0 top-0 z-5 h-full w-full overflow-hidden"
data-face-left={faceBoxPosition.left} data-face-left={faceBoxPosition.left}
data-face-top={faceBoxPosition.top} data-face-top={faceBoxPosition.top}
@@ -305,7 +449,9 @@
<div <div
id="face-selector" id="face-selector"
bind:this={faceSelectorEl} bind:this={faceSelectorEl}
class="absolute top-[calc(50%-250px)] start-[calc(50%-125px)] max-w-[250px] w-[250px] bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800 transition-[top,left] duration-200 ease-out" class="fixed z-20 w-[min(200px,45vw)] min-w-48 bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800 transition-[top,left] duration-200 ease-out"
use:trapEvents
onwheel={(e) => e.stopPropagation()}
> >
<p class="text-center text-sm">{$t('select_person_to_tag')}</p> <p class="text-center text-sm">{$t('select_person_to_tag')}</p>
@@ -346,4 +492,15 @@
<Button size="small" fullWidth onclick={cancel} color="danger" class="mt-2">{$t('cancel')}</Button> <Button size="small" fullWidth onclick={cancel} color="danger" class="mt-2">{$t('cancel')}</Button>
</div> </div>
{#if isZoomed && !panModifierHeld}
<div
transition:fade={{ duration: 200 }}
class="absolute bottom-4 inset-s-1/2 -translate-x-1/2 pointer-events-none z-10"
>
<p class="bg-black/60 text-white text-xs px-3 py-1.5 rounded-full whitespace-nowrap">
{$t('hold_key_to_pan', { values: { key: panModifierLabel } })}
</p>
</div>
{/if}
</div> </div>
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import type { OcrBox } from '$lib/utils/ocr-utils'; import type { OcrBox } from '$lib/utils/ocr-utils';
import { calculateBoundingBoxMatrix, calculateFittedFontSize } from '$lib/utils/ocr-utils'; import { calculateBoundingBoxMatrix, calculateFittedFontSize } from '$lib/utils/ocr-utils';
@@ -8,6 +9,7 @@
let { ocrBox }: Props = $props(); let { ocrBox }: Props = $props();
const isTouch = $derived(mediaQueryManager.pointerCoarse);
const dimensions = $derived(calculateBoundingBoxMatrix(ocrBox.points)); const dimensions = $derived(calculateBoundingBoxMatrix(ocrBox.points));
const transform = $derived(`matrix3d(${dimensions.matrix.join(',')})`); const transform = $derived(`matrix3d(${dimensions.matrix.join(',')})`);
@@ -15,13 +17,23 @@
calculateFittedFontSize(ocrBox.text, dimensions.width, dimensions.height, ocrBox.verticalMode) + 'px', calculateFittedFontSize(ocrBox.text, dimensions.width, dimensions.height, ocrBox.verticalMode) + 'px',
); );
const handleSelectStart = (event: Event) => {
const target = event.currentTarget as HTMLElement;
requestAnimationFrame(() => {
const selection = globalThis.getSelection();
if (selection) {
selection.selectAllChildren(target);
}
});
};
const verticalStyle = $derived.by(() => { const verticalStyle = $derived.by(() => {
switch (ocrBox.verticalMode) { switch (ocrBox.verticalMode) {
case 'cjk': { case 'cjk': {
return ' writing-mode: vertical-rl;'; return 'writing-mode: vertical-rl;';
} }
case 'rotated': { case 'rotated': {
return ' writing-mode: vertical-rl; text-orientation: sideways;'; return 'writing-mode: vertical-rl; text-orientation: sideways;';
} }
default: { default: {
return ''; return '';
@@ -30,17 +42,23 @@
}); });
</script> </script>
<div class="absolute left-0 top-0"> <div
<div class={[
class="absolute flex items-center justify-center text-transparent border-2 border-blue-500 bg-blue-500/10 pointer-events-auto cursor-text select-text transition-colors hover:z-1 hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3 focus:z-1 focus:text-white focus:bg-black/60 focus:border-blue-600 focus:border-3 focus:outline-none {ocrBox.verticalMode === 'absolute left-0 top-0 flex items-center justify-center',
'none' 'border-2 border-blue-500 pointer-events-auto cursor-text',
? 'px-2 py-1 whitespace-nowrap' 'focus:z-1 focus:border-blue-600 focus:border-3 focus:outline-none',
: 'px-1 py-2'}" isTouch
style="font-size: {fontSize}; width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: 0 0;{verticalStyle}" ? 'text-white bg-black/60 select-all'
tabindex="0" : 'select-text text-transparent bg-blue-500/10 transition-colors hover:z-1 hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3',
role="button" ocrBox.verticalMode === 'none' ? 'px-2 py-1 whitespace-nowrap' : 'px-1 py-2',
aria-label={ocrBox.text} ]}
> style="font-size: {fontSize}; width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: 0 0; touch-action: none; {verticalStyle}"
{ocrBox.text} data-testid="ocr-box"
</div> data-overlay-interactive
tabindex="0"
role="button"
aria-label={ocrBox.text}
onselectstart={isTouch ? handleSelectStart : undefined}
>
{ocrBox.text}
</div> </div>
@@ -128,10 +128,8 @@
} }
const boxes = getOcrBoundingBoxes(ocrData, { const boxes = getOcrBoundingBoxes(ocrData, {
contentWidth: viewer.state.textureData.panoData.croppedWidth, width: viewer.state.textureData.panoData.croppedWidth,
contentHeight: viewer.state.textureData.panoData.croppedHeight, height: viewer.state.textureData.panoData.croppedHeight,
offsetX: 0,
offsetY: 0,
}); });
for (const [index, box] of boxes.entries()) { for (const [index, box] of boxes.entries()) {
@@ -5,16 +5,17 @@
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte'; import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte'; import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte'; import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
import ZoomMinimap from '$lib/components/asset-viewer/zoom-minimap.svelte';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte'; import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte'; import { castManager } from '$lib/managers/cast-manager.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { isEditFacesPanelOpen, isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte'; import { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store'; import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils'; import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { getNaturalSize, scaleToFit, type ContentMetrics } from '$lib/utils/container-utils'; import type { Size } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils'; import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
import { getBoundingBox } from '$lib/utils/people-utils'; import { getBoundingBox } from '$lib/utils/people-utils';
@@ -25,14 +26,14 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { AssetCursor } from './asset-viewer.svelte'; import type { AssetCursor } from './asset-viewer.svelte';
interface Props { type Props = {
cursor: AssetCursor; cursor: AssetCursor;
element?: HTMLDivElement; element?: HTMLDivElement;
sharedLink?: SharedLinkResponseDto; sharedLink?: SharedLinkResponseDto;
onReady?: () => void; onReady?: () => void;
onError?: () => void; onError?: () => void;
onSwipe?: (event: SwipeCustomEvent) => void; onSwipe?: (event: SwipeCustomEvent) => void;
} };
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props(); let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
@@ -67,23 +68,12 @@
height: containerHeight, height: containerHeight,
}); });
const overlayMetrics = $derived.by((): ContentMetrics => { let imageDimensions = $state<Size>({ width: 0, height: 0 });
if (!assetViewerManager.imgRef || !visibleImageReady) { let scaledDimensions = $state<Size>({ width: 0, height: 0 });
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
}
const natural = getNaturalSize(assetViewerManager.imgRef); const overlaySize = $derived(visibleImageReady ? scaledDimensions : { width: 0, height: 0 });
const scaled = scaleToFit(natural, { width: containerWidth, height: containerHeight });
return { const ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlaySize) : []);
contentWidth: scaled.width,
contentHeight: scaled.height,
offsetX: 0,
offsetY: 0,
};
});
const ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlayMetrics) : []);
const onCopy = async () => { const onCopy = async () => {
if (!canCopyImageToClipboard() || !assetViewerManager.imgRef) { if (!canCopyImageToClipboard() || !assetViewerManager.imgRef) {
@@ -105,12 +95,6 @@
const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow); const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow);
$effect(() => {
if (isFaceEditMode.value && assetViewerManager.zoom > 1) {
onZoom();
}
});
// TODO move to action + command palette // TODO move to action + command palette
const onCopyShortcut = (event: KeyboardEvent) => { const onCopyShortcut = (event: KeyboardEvent) => {
if (globalThis.getSelection()?.type === 'Range') { if (globalThis.getSelection()?.type === 'Range') {
@@ -151,48 +135,26 @@
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash, $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash,
); );
let adaptiveImage = $state<HTMLDivElement | undefined>();
const faceToNameMap = $derived.by(() => { const faceToNameMap = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity // eslint-disable-next-line svelte/prefer-svelte-reactivity
const map = new Map<Faces, string>(); const map = new Map<Faces, string | undefined>();
for (const person of asset.people ?? []) { for (const person of asset.people ?? []) {
for (const face of person.faces ?? []) { for (const face of person.faces ?? []) {
map.set(face, person.name); map.set(face, person.name);
} }
} }
for (const face of asset.unassignedFaces ?? []) {
map.set(face, undefined);
}
return map; return map;
}); });
// Array needed for indexed access in the template (faces[index])
const faces = $derived(Array.from(faceToNameMap.keys())); const faces = $derived(Array.from(faceToNameMap.keys()));
const boundingBoxes = $derived(getBoundingBox(faces, overlaySize));
const handleImageMouseMove = (event: MouseEvent) => { const activeBoundingBoxes = $derived(boundingBoxes.filter((box) => $boundingBoxesArray.some((f) => f.id === box.id)));
$boundingBoxesArray = [];
if (!assetViewerManager.imgRef || !element || isFaceEditMode.value || ocrManager.showOverlay) {
return;
}
const natural = getNaturalSize(assetViewerManager.imgRef);
const scaled = scaleToFit(natural, container);
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
const contentOffsetX = (container.width - scaled.width) / 2;
const contentOffsetY = (container.height - scaled.height) / 2;
const containerRect = element.getBoundingClientRect();
const mouseX = (event.clientX - containerRect.left - contentOffsetX * currentZoom - currentPositionX) / currentZoom;
const mouseY = (event.clientY - containerRect.top - contentOffsetY * currentZoom - currentPositionY) / currentZoom;
const faceBoxes = getBoundingBox(faces, overlayMetrics);
for (const [index, box] of faceBoxes.entries()) {
if (mouseX >= box.left && mouseX <= box.left + box.width && mouseY >= box.top && mouseY <= box.top + box.height) {
$boundingBoxesArray.push(faces[index]);
}
}
};
const handleImageMouseLeave = () => {
$boundingBoxesArray = [];
};
</script> </script>
<AssetViewerEvents {onCopy} {onZoom} /> <AssetViewerEvents {onCopy} {onZoom} />
@@ -213,9 +175,7 @@
bind:clientHeight={containerHeight} bind:clientHeight={containerHeight}
role="presentation" role="presentation"
ondblclick={onZoom} ondblclick={onZoom}
onmousemove={handleImageMouseMove} use:zoomImageAction={{ zoomTarget: adaptiveImage }}
onmouseleave={handleImageMouseLeave}
use:zoomImageAction={{ disabled: isFaceEditMode.value || ocrManager.showOverlay }}
{...useSwipe((event) => onSwipe?.(event))} {...useSwipe((event) => onSwipe?.(event))}
> >
<AdaptiveImage <AdaptiveImage
@@ -233,6 +193,9 @@
onReady?.(); onReady?.();
}} }}
bind:imgRef={assetViewerManager.imgRef} bind:imgRef={assetViewerManager.imgRef}
bind:imgNaturalSize={imageDimensions}
bind:imgScaledSize={scaledDimensions}
bind:ref={adaptiveImage}
> >
{#snippet backdrop()} {#snippet backdrop()}
{#if blurredSlideshow} {#if blurredSlideshow}
@@ -243,20 +206,40 @@
{/if} {/if}
{/snippet} {/snippet}
{#snippet overlays()} {#snippet overlays()}
{#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox, index (boundingbox.id)} {#if !isFaceEditMode.value}
{#each boundingBoxes as boundingbox, index (boundingbox.id)}
{@const face = faces[index]}
{@const name = faceToNameMap.get(face)}
{#if name !== undefined || isEditFacesPanelOpen.value}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute pointer-events-auto outline-none rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
aria-label="{$t('person')}: {name ?? $t('unknown')}"
onpointerenter={() => ($boundingBoxesArray = [face])}
onpointerleave={() => ($boundingBoxesArray = [])}
></div>
{/if}
{/each}
{/if}
{#each activeBoundingBoxes as boundingbox (boundingbox.id)}
{@const face = faces.find((f) => f.id === boundingbox.id)}
{@const name = face ? faceToNameMap.get(face) : undefined}
<div <div
class="absolute border-solid border-white border-3 rounded-lg" class="absolute border-solid border-white border-3 rounded-lg pointer-events-none"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;" style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
></div> >
{#if faceToNameMap.get($boundingBoxesArray[index])} {#if name}
<div <div
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap pointer-events-none shadow-lg" aria-hidden="true"
style="top: {boundingbox.top + boundingbox.height + 4}px; left: {boundingbox.left + class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap shadow-lg"
boundingbox.width}px; transform: translateX(-100%);" style="top: {boundingbox.height + 4}px; right: 0;"
> >
{faceToNameMap.get($boundingBoxesArray[index])} {name}
</div> </div>
{/if} {/if}
</div>
{/each} {/each}
{#each ocrBoxes as ocrBox (ocrBox.id)} {#each ocrBoxes as ocrBox (ocrBox.id)}
@@ -265,7 +248,9 @@
{/snippet} {/snippet}
</AdaptiveImage> </AdaptiveImage>
<ZoomMinimap {containerWidth} {containerHeight} {asset} {sharedLink} />
{#if isFaceEditMode.value && assetViewerManager.imgRef} {#if isFaceEditMode.value && assetViewerManager.imgRef}
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} /> <FaceEditor imageSize={imageDimensions} {containerWidth} {containerHeight} assetId={asset.id} />
{/if} {/if}
</div> </div>
@@ -11,14 +11,16 @@
videoViewerVolume, videoViewerVolume,
} from '$lib/stores/preferences.store'; } from '$lib/stores/preferences.store';
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils'; import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import type { Size } from '$lib/utils/container-utils';
import { AssetMediaSize } from '@immich/sdk'; import { AssetMediaSize } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui'; import { LoadingSpinner } from '@immich/ui';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures'; import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
interface Props { type Props = {
assetId: string; assetId: string;
imageSize: Size;
loopVideo: boolean; loopVideo: boolean;
cacheKey: string | null; cacheKey: string | null;
playOriginalVideo: boolean; playOriginalVideo: boolean;
@@ -27,10 +29,11 @@
onVideoEnded?: () => void; onVideoEnded?: () => void;
onVideoStarted?: () => void; onVideoStarted?: () => void;
onClose?: () => void; onClose?: () => void;
} };
let { let {
assetId, assetId,
imageSize,
loopVideo, loopVideo,
cacheKey, cacheKey,
playOriginalVideo, playOriginalVideo,
@@ -173,7 +176,7 @@
{/if} {/if}
{#if isFaceEditMode.value} {#if isFaceEditMode.value}
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} /> <FaceEditor {imageSize} {containerWidth} {containerHeight} {assetId} />
{/if} {/if}
{/if} {/if}
</div> </div>
@@ -4,7 +4,7 @@
import { ProjectionType } from '$lib/constants'; import { ProjectionType } from '$lib/constants';
import type { AssetResponseDto } from '@immich/sdk'; import type { AssetResponseDto } from '@immich/sdk';
interface Props { type Props = {
asset: AssetResponseDto; asset: AssetResponseDto;
assetId?: string; assetId?: string;
projectionType: string | null | undefined; projectionType: string | null | undefined;
@@ -16,7 +16,7 @@
onNextAsset?: () => void; onNextAsset?: () => void;
onVideoEnded?: () => void; onVideoEnded?: () => void;
onVideoStarted?: () => void; onVideoStarted?: () => void;
} };
let { let {
asset, asset,
@@ -42,6 +42,7 @@
{loopVideo} {loopVideo}
{cacheKey} {cacheKey}
assetId={effectiveAssetId} assetId={effectiveAssetId}
imageSize={{ width: asset.width ?? 1, height: asset.height ?? 1 }}
{playOriginalVideo} {playOriginalVideo}
{onPreviousAsset} {onPreviousAsset}
{onNextAsset} {onNextAsset}
@@ -0,0 +1,272 @@
<script lang="ts">
import { MAX_ZOOM } from '$lib/actions/zoom-image';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { getAssetUrls } from '$lib/utils';
import { scaleToFit, type ContentMetrics } from '$lib/utils/container-utils';
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
import { TUNABLES } from '$lib/utils/tunables';
import { clamp } from 'lodash-es';
import { fade } from 'svelte/transition';
interface Props {
containerWidth: number;
containerHeight: number;
asset: AssetResponseDto;
sharedLink?: SharedLinkResponseDto;
}
let { containerWidth, containerHeight, asset, sharedLink }: Props = $props();
const MINIMAP_MAX = 192;
const MINIMAP_MIN = 100;
const minimapSize = $derived(clamp(Math.min(containerWidth, containerHeight) * 0.25, MINIMAP_MIN, MINIMAP_MAX));
const thumbnailUrl = $derived(getAssetUrls(asset, sharedLink).thumbnail);
const imageDimensions = $derived({
width: asset.width && asset.width > 0 ? asset.width : 1,
height: asset.height && asset.height > 0 ? asset.height : 1,
});
const container = $derived({ width: containerWidth, height: containerHeight });
// Scale the full container into the minimap square
const containerInMinimap = $derived(scaleToFit(container, { width: minimapSize, height: minimapSize }));
const minimapContainerScale = $derived(containerInMinimap.width / containerWidth);
const containerOffsetX = $derived((minimapSize - containerInMinimap.width) / 2);
const containerOffsetY = $derived((minimapSize - containerInMinimap.height) / 2);
// Position the image within the minimap's container representation
const imageInMinimap: ContentMetrics = $derived.by(() => {
const fitted = scaleToFit(imageDimensions, containerInMinimap);
return {
contentWidth: fitted.width,
contentHeight: fitted.height,
offsetX: containerOffsetX + (containerInMinimap.width - fitted.width) / 2,
offsetY: containerOffsetY + (containerInMinimap.height - fitted.height) / 2,
};
});
const { FADE_DURATION, HIDE_DELAY } = TUNABLES.MINIMAP;
let isDragging = $state(false);
let isDraggingZoom = $state(false);
let isRecentActivity = $state(false);
let hideTimer: ReturnType<typeof setTimeout> | null = null;
const resetHideTimer = () => {
isRecentActivity = true;
if (hideTimer !== null) {
clearTimeout(hideTimer);
}
hideTimer = setTimeout(() => {
isRecentActivity = false;
hideTimer = null;
}, HIDE_DELAY);
};
const isZoomed = $derived(assetViewerManager.zoom > 1);
const isVisible = $derived((isZoomed && isRecentActivity) || isDragging || isDraggingZoom);
$effect(() => {
// Track zoom state changes to reset the hide timer
const _state = assetViewerManager.zoomState;
void _state;
if (isZoomed) {
resetHideTimer();
}
return () => {
if (hideTimer !== null) {
clearTimeout(hideTimer);
hideTimer = null;
}
};
});
const zoomPercent = $derived(((assetViewerManager.zoom - 1) / (MAX_ZOOM - 1)) * 100);
const zoomLabel = $derived(assetViewerManager.zoom.toFixed(1) + 'x');
const clampPanPosition = (positionX: number, positionY: number, zoom: number) => ({
positionX: clamp(positionX, -(containerWidth * (zoom - 1)), 0),
positionY: clamp(positionY, -(containerHeight * (zoom - 1)), 0),
});
const minimapToContainerPosition = (minimapX: number, minimapY: number) => {
const containerX = (minimapX - containerOffsetX) / minimapContainerScale;
const containerY = (minimapY - containerOffsetY) / minimapContainerScale;
const { currentZoom } = assetViewerManager.zoomState;
const rawPositionX = containerWidth / 2 - containerX * currentZoom;
const rawPositionY = containerHeight / 2 - containerY * currentZoom;
return clampPanPosition(rawPositionX, rawPositionY, currentZoom);
};
const panToMinimapPosition = (event: PointerEvent) => {
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const minimapX = event.clientX - rect.left;
const minimapY = event.clientY - rect.top;
const { positionX, positionY } = minimapToContainerPosition(minimapX, minimapY);
assetViewerManager.directTransform({ currentPositionX: positionX, currentPositionY: positionY });
};
const onPointerDown = (event: PointerEvent) => {
if (event.button !== 0) {
return;
}
isDragging = true;
assetViewerManager.setZoomEnabled(false);
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
panToMinimapPosition(event);
event.preventDefault();
event.stopPropagation();
};
const onPointerMove = (event: PointerEvent) => {
if (!isDragging) {
return;
}
panToMinimapPosition(event);
};
const onPointerUp = () => {
isDragging = false;
assetViewerManager.setZoomEnabled(true);
};
const zoomAroundCenter = (newZoom: number) => {
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
const centerX = containerWidth / 2;
const centerY = containerHeight / 2;
const zoomTargetX = (centerX - currentPositionX) / currentZoom;
const zoomTargetY = (centerY - currentPositionY) / currentZoom;
const newPositionX = -zoomTargetX * newZoom + centerX;
const newPositionY = -zoomTargetY * newZoom + centerY;
assetViewerManager.directTransform({
currentZoom: newZoom,
currentPositionX: clamp(newPositionX, -(containerWidth * (newZoom - 1)), 0),
currentPositionY: clamp(newPositionY, -(containerHeight * (newZoom - 1)), 0),
});
};
const setZoomFromSlider = (event: PointerEvent) => {
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const percent = clamp((event.clientX - rect.left) / rect.width, 0, 1);
zoomAroundCenter(1 + percent * (MAX_ZOOM - 1));
};
const WHEEL_ZOOM_RATIO = 0.1;
const onWheel = (event: WheelEvent) => {
event.preventDefault();
const { currentZoom } = assetViewerManager.zoomState;
const delta = -clamp(event.deltaY, -0.5, 0.5);
const newZoom = clamp(currentZoom + delta * WHEEL_ZOOM_RATIO * currentZoom, 1, MAX_ZOOM);
zoomAroundCenter(newZoom);
};
const onZoomSliderPointerDown = (event: PointerEvent) => {
if (event.button !== 0) {
return;
}
isDraggingZoom = true;
assetViewerManager.setZoomEnabled(false);
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
setZoomFromSlider(event);
event.preventDefault();
event.stopPropagation();
};
const onZoomSliderPointerMove = (event: PointerEvent) => {
if (!isDraggingZoom) {
return;
}
setZoomFromSlider(event);
};
const onZoomSliderPointerUp = () => {
isDraggingZoom = false;
assetViewerManager.setZoomEnabled(true);
};
const viewportRect = $derived.by(() => {
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
// Visible area in container coordinates
const visibleLeft = -currentPositionX / currentZoom;
const visibleTop = -currentPositionY / currentZoom;
const visibleWidth = containerWidth / currentZoom;
const visibleHeight = containerHeight / currentZoom;
// Map to minimap coordinates
return {
left: visibleLeft * minimapContainerScale + containerOffsetX,
top: visibleTop * minimapContainerScale + containerOffsetY,
width: visibleWidth * minimapContainerScale,
height: visibleHeight * minimapContainerScale,
};
});
</script>
{#if isVisible}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute top-[68px] right-14 md:right-4 z-10 rounded-lg border border-white/30 bg-black/60 p-1 backdrop-blur-sm"
data-testid="zoom-minimap"
transition:fade={{ duration: FADE_DURATION }}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative overflow-hidden rounded bg-black"
class:cursor-grabbing={isDragging}
class:cursor-pointer={!isDragging}
data-testid="zoom-minimap-canvas"
style="width: {minimapSize}px; height: {minimapSize}px;"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
onwheel={onWheel}
>
<img
src={thumbnailUrl}
alt=""
class="absolute pointer-events-none"
draggable="false"
style="left: {imageInMinimap.offsetX}px; top: {imageInMinimap.offsetY}px; width: {imageInMinimap.contentWidth}px; height: {imageInMinimap.contentHeight}px;"
/>
<div
class={[
'absolute border-2 border-white bg-white/20 pointer-events-none rounded-sm',
isDragging && 'border-white/80',
]}
data-testid="zoom-minimap-viewport"
style="left: {viewportRect.left}px; top: {viewportRect.top}px; width: {viewportRect.width}px; height: {viewportRect.height}px;"
></div>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative mt-1 h-3 rounded-full bg-white/20 cursor-pointer"
class:cursor-grabbing={isDraggingZoom}
data-testid="zoom-minimap-slider"
style="width: {minimapSize}px;"
onpointerdown={onZoomSliderPointerDown}
onpointermove={onZoomSliderPointerMove}
onpointerup={onZoomSliderPointerUp}
onpointercancel={onZoomSliderPointerUp}
>
<div
class="absolute top-0 left-0 h-full rounded-full bg-white/80 pointer-events-none"
data-testid="zoom-minimap-slider-fill"
style="width: {zoomPercent}%;"
></div>
<span
class="absolute inset-0 flex items-center justify-center text-[9px] font-semibold pointer-events-none select-none leading-none"
style="color: #000; text-shadow: 0 0 3px rgba(255,255,255,0.8);"
>
{zoomLabel}
</span>
</div>
</div>
{/if}
@@ -16,6 +16,7 @@
circle?: boolean; circle?: boolean;
hidden?: boolean; hidden?: boolean;
border?: boolean; border?: boolean;
highlighted?: boolean;
hiddenIconClass?: string; hiddenIconClass?: string;
class?: ClassValue; class?: ClassValue;
brokenAssetClass?: ClassValue; brokenAssetClass?: ClassValue;
@@ -34,6 +35,7 @@
circle = false, circle = false,
hidden = false, hidden = false,
border = false, border = false,
highlighted = false,
hiddenIconClass = 'text-white', hiddenIconClass = 'text-white',
onComplete = undefined, onComplete = undefined,
class: imageClass = '', class: imageClass = '',
@@ -83,6 +85,10 @@
/> />
{/if} {/if}
{#if highlighted}
<span class={['absolute inset-0 pointer-events-none border-2 border-white', sharedClasses]} {style}></span>
{/if}
{#if hidden} {#if hidden}
<div class="absolute start-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform"> <div class="absolute start-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
<!-- TODO fix `title` type --> <!-- TODO fix `title` type -->
@@ -27,12 +27,12 @@
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import AssignFaceSidePanel from './assign-face-side-panel.svelte'; import AssignFaceSidePanel from './assign-face-side-panel.svelte';
interface Props { type Props = {
assetId: string; assetId: string;
assetType: AssetTypeEnum; assetType: AssetTypeEnum;
onClose: () => void; onClose: () => void;
onRefresh: () => void; onRefresh: () => void;
} };
let { assetId, assetType, onClose, onRefresh }: Props = $props(); let { assetId, assetType, onClose, onRefresh }: Props = $props();
@@ -58,6 +58,8 @@
let automaticRefreshTimeout: ReturnType<typeof setTimeout>; let automaticRefreshTimeout: ReturnType<typeof setTimeout>;
const thumbnailWidth = '90px'; const thumbnailWidth = '90px';
const focusHighlightClass =
'group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-immich-primary dark:group-focus-visible:outline-immich-dark-primary';
async function loadPeople() { async function loadPeople() {
const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner); const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner);
@@ -226,14 +228,16 @@
{:else} {:else}
{#each peopleWithFaces as face, index (face.id)} {#each peopleWithFaces as face, index (face.id)}
{@const personName = face.person ? face.person?.name : $t('face_unassigned')} {@const personName = face.person ? face.person?.name : $t('face_unassigned')}
{@const isHighlighted = $boundingBoxesArray.some((f) => f.id === face.id)}
<div class="relative h-29 w-24"> <div class="relative h-29 w-24">
<div <div
role="button" role="button"
tabindex={index} tabindex={index}
class="absolute start-0 top-0 h-22.5 w-22.5 cursor-default" data-testid="face-thumbnail"
class="group absolute inset-s-0 top-0 h-22.5 w-22.5 cursor-default outline-none"
onfocus={() => ($boundingBoxesArray = [peopleWithFaces[index]])} onfocus={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
onmouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])} onpointerover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
onmouseleave={() => ($boundingBoxesArray = [])} onpointerleave={() => ($boundingBoxesArray = [])}
> >
<div class="relative"> <div class="relative">
{#if selectedPersonToCreate[face.id]} {#if selectedPersonToCreate[face.id]}
@@ -245,6 +249,8 @@
title={$t('new_person')} title={$t('new_person')}
widthStyle={thumbnailWidth} widthStyle={thumbnailWidth}
heightStyle={thumbnailWidth} heightStyle={thumbnailWidth}
highlighted={isHighlighted}
class={focusHighlightClass}
/> />
{:else if selectedPersonToReassign[face.id]} {:else if selectedPersonToReassign[face.id]}
<ImageThumbnail <ImageThumbnail
@@ -259,6 +265,8 @@
widthStyle={thumbnailWidth} widthStyle={thumbnailWidth}
heightStyle={thumbnailWidth} heightStyle={thumbnailWidth}
hidden={selectedPersonToReassign[face.id].isHidden} hidden={selectedPersonToReassign[face.id].isHidden}
highlighted={isHighlighted}
class={focusHighlightClass}
/> />
{:else if face.person} {:else if face.person}
<ImageThumbnail <ImageThumbnail
@@ -270,6 +278,8 @@
widthStyle={thumbnailWidth} widthStyle={thumbnailWidth}
heightStyle={thumbnailWidth} heightStyle={thumbnailWidth}
hidden={face.person.isHidden} hidden={face.person.isHidden}
highlighted={isHighlighted}
class={focusHighlightClass}
/> />
{:else} {:else}
{#await zoomImageToBase64(face, assetId, assetType, assetViewerManager.imgRef)} {#await zoomImageToBase64(face, assetId, assetType, assetViewerManager.imgRef)}
@@ -281,6 +291,8 @@
title={$t('face_unassigned')} title={$t('face_unassigned')}
widthStyle="90px" widthStyle="90px"
heightStyle="90px" heightStyle="90px"
highlighted={isHighlighted}
class={focusHighlightClass}
/> />
{:then data} {:then data}
<ImageThumbnail <ImageThumbnail
@@ -291,6 +303,8 @@
title={$t('face_unassigned')} title={$t('face_unassigned')}
widthStyle="90px" widthStyle="90px"
heightStyle="90px" heightStyle="90px"
highlighted={isHighlighted}
class={focusHighlightClass}
/> />
{/await} {/await}
{/if} {/if}
@@ -18,6 +18,8 @@ const createDefaultZoomState = (): ZoomImageWheelState => ({
export type Events = { export type Events = {
Zoom: []; Zoom: [];
ZoomChange: [ZoomImageWheelState]; ZoomChange: [ZoomImageWheelState];
DirectTransform: [ZoomImageWheelState];
ZoomEnabled: [boolean];
Copy: []; Copy: [];
}; };
@@ -87,6 +89,15 @@ export class AssetViewerManager extends BaseEventManager<Events> {
this.#zoomState = state; this.#zoomState = state;
} }
directTransform(state: Partial<ZoomImageWheelState>) {
this.#zoomState = { ...this.#zoomState, ...state };
this.emit('DirectTransform', this.#zoomState);
}
setZoomEnabled(enabled: boolean) {
this.emit('ZoomEnabled', enabled);
}
cancelZoomAnimation() { cancelZoomAnimation() {
if (this.#animationFrameId !== null) { if (this.#animationFrameId !== null) {
cancelAnimationFrame(this.#animationFrameId); cancelAnimationFrame(this.#animationFrameId);
@@ -139,7 +150,6 @@ export class AssetViewerManager extends BaseEventManager<Events> {
openEditor() { openEditor() {
this.closeActivityPanel(); this.closeActivityPanel();
this.isPlayingMotionPhoto = false;
this.isShowEditor = true; this.isShowEditor = true;
} }

Some files were not shown because too many files have changed in this diff Show More