mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 137ef8cf51 | |||
| 590a9df7ec | |||
| ed04d87273 |
@@ -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
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}'
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}'
|
||||||
@@ -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}}'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Vendored
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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,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"
|
||||||
|
|||||||
Generated
+1
-1
@@ -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" },
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
+14
-16
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Generated
+1
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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', () {
|
||||||
|
|||||||
@@ -8409,7 +8409,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/MergeFaceClusterDto"
|
"$ref": "#/components/schemas/MergePersonDto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -15166,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": [
|
||||||
@@ -18878,10 +18878,10 @@
|
|||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"MergeFaceClusterDto": {
|
"MergePersonDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"ids": {
|
"ids": {
|
||||||
"description": "Face cluster IDs to merge",
|
"description": "Person IDs to merge",
|
||||||
"items": {
|
"items": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -23055,70 +23055,6 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"SyncAssetFaceV3": {
|
|
||||||
"properties": {
|
|
||||||
"assetId": {
|
|
||||||
"description": "Asset ID",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"boundingBoxX1": {
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"boundingBoxX2": {
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"boundingBoxY1": {
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"boundingBoxY2": {
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"deletedAt": {
|
|
||||||
"description": "Face deleted at",
|
|
||||||
"format": "date-time",
|
|
||||||
"nullable": true,
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"faceClusterId": {
|
|
||||||
"description": "Face cluster ID",
|
|
||||||
"nullable": true,
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"id": {
|
|
||||||
"description": "Asset face ID",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"imageHeight": {
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"imageWidth": {
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"isVisible": {
|
|
||||||
"description": "Is the face visible in the asset",
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"sourceType": {
|
|
||||||
"description": "Source type",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"assetId",
|
|
||||||
"boundingBoxX1",
|
|
||||||
"boundingBoxX2",
|
|
||||||
"boundingBoxY1",
|
|
||||||
"boundingBoxY2",
|
|
||||||
"deletedAt",
|
|
||||||
"faceClusterId",
|
|
||||||
"id",
|
|
||||||
"imageHeight",
|
|
||||||
"imageWidth",
|
|
||||||
"isVisible",
|
|
||||||
"sourceType"
|
|
||||||
],
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"SyncAssetMetadataDeleteV1": {
|
"SyncAssetMetadataDeleteV1": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"assetId": {
|
"assetId": {
|
||||||
@@ -23412,14 +23348,10 @@
|
|||||||
"StackV1",
|
"StackV1",
|
||||||
"StackDeleteV1",
|
"StackDeleteV1",
|
||||||
"PersonV1",
|
"PersonV1",
|
||||||
"PersonV2",
|
|
||||||
"PersonDeleteV1",
|
"PersonDeleteV1",
|
||||||
"AssetFaceV1",
|
"AssetFaceV1",
|
||||||
"AssetFaceV2",
|
"AssetFaceV2",
|
||||||
"AssetFaceV3",
|
|
||||||
"AssetFaceDeleteV1",
|
"AssetFaceDeleteV1",
|
||||||
"FaceClusterV1",
|
|
||||||
"FaceClusterDeleteV1",
|
|
||||||
"UserMetadataV1",
|
"UserMetadataV1",
|
||||||
"UserMetadataDeleteV1",
|
"UserMetadataDeleteV1",
|
||||||
"SyncAckV1",
|
"SyncAckV1",
|
||||||
@@ -23428,47 +23360,6 @@
|
|||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"SyncFaceClusterDeleteV1": {
|
|
||||||
"properties": {
|
|
||||||
"faceClusterId": {
|
|
||||||
"description": "Face cluster ID",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"faceClusterId"
|
|
||||||
],
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"SyncFaceClusterV1": {
|
|
||||||
"properties": {
|
|
||||||
"createdAt": {
|
|
||||||
"description": "Created at",
|
|
||||||
"format": "date-time",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"id": {
|
|
||||||
"description": "Face cluster ID",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"ownerId": {
|
|
||||||
"description": "Owner ID",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"updatedAt": {
|
|
||||||
"description": "Updated at",
|
|
||||||
"format": "date-time",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"createdAt",
|
|
||||||
"id",
|
|
||||||
"ownerId",
|
|
||||||
"updatedAt"
|
|
||||||
],
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"SyncMemoryAssetDeleteV1": {
|
"SyncMemoryAssetDeleteV1": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"assetId": {
|
"assetId": {
|
||||||
@@ -23711,75 +23602,6 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"SyncPersonV2": {
|
|
||||||
"properties": {
|
|
||||||
"birthDate": {
|
|
||||||
"description": "Birth date",
|
|
||||||
"format": "date-time",
|
|
||||||
"nullable": true,
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"color": {
|
|
||||||
"description": "Color",
|
|
||||||
"nullable": true,
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"createdAt": {
|
|
||||||
"description": "Created at",
|
|
||||||
"format": "date-time",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"faceAssetId": {
|
|
||||||
"description": "Face asset ID",
|
|
||||||
"nullable": true,
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"faceClusterId": {
|
|
||||||
"description": "Face cluster ID",
|
|
||||||
"nullable": true,
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"id": {
|
|
||||||
"description": "Person ID",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"isFavorite": {
|
|
||||||
"description": "Is favorite",
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"isHidden": {
|
|
||||||
"description": "Is hidden",
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"description": "Person name",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"ownerId": {
|
|
||||||
"description": "Owner ID",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"updatedAt": {
|
|
||||||
"description": "Updated at",
|
|
||||||
"format": "date-time",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"birthDate",
|
|
||||||
"color",
|
|
||||||
"createdAt",
|
|
||||||
"faceAssetId",
|
|
||||||
"faceClusterId",
|
|
||||||
"id",
|
|
||||||
"isFavorite",
|
|
||||||
"isHidden",
|
|
||||||
"name",
|
|
||||||
"ownerId",
|
|
||||||
"updatedAt"
|
|
||||||
],
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"SyncRequestType": {
|
"SyncRequestType": {
|
||||||
"description": "Sync request types",
|
"description": "Sync request types",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -23802,11 +23624,8 @@
|
|||||||
"StacksV1",
|
"StacksV1",
|
||||||
"UsersV1",
|
"UsersV1",
|
||||||
"PeopleV1",
|
"PeopleV1",
|
||||||
"PeopleV2",
|
|
||||||
"AssetFacesV1",
|
"AssetFacesV1",
|
||||||
"AssetFacesV2",
|
"AssetFacesV2",
|
||||||
"AssetFacesV3",
|
|
||||||
"FaceClusterV1",
|
|
||||||
"UserMetadataV1"
|
"UserMetadataV1"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
+1
-1
@@ -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",
|
||||||
|
|||||||
Generated
+250
-527
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich",
|
"name": "immich",
|
||||||
"version": "2.6.2",
|
"version": "2.6.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
|||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import {
|
import {
|
||||||
AssetFaceUpdateDto,
|
AssetFaceUpdateDto,
|
||||||
MergeFaceClusterDto,
|
MergePersonDto,
|
||||||
PeopleResponseDto,
|
PeopleResponseDto,
|
||||||
PeopleUpdateDto,
|
PeopleUpdateDto,
|
||||||
PersonCreateDto,
|
PersonCreateDto,
|
||||||
@@ -182,7 +182,7 @@ export class PersonController {
|
|||||||
mergePerson(
|
mergePerson(
|
||||||
@Auth() auth: AuthDto,
|
@Auth() auth: AuthDto,
|
||||||
@Param() { id }: UUIDParamDto,
|
@Param() { id }: UUIDParamDto,
|
||||||
@Body() dto: MergeFaceClusterDto,
|
@Body() dto: MergePersonDto,
|
||||||
): Promise<BulkIdResponseDto[]> {
|
): Promise<BulkIdResponseDto[]> {
|
||||||
return this.service.mergePerson(auth, id, dto);
|
return this.service.mergePerson(auth, id, dto);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ export type AssetFace = {
|
|||||||
boundingBoxY2: number;
|
boundingBoxY2: number;
|
||||||
imageHeight: number;
|
imageHeight: number;
|
||||||
imageWidth: number;
|
imageWidth: number;
|
||||||
faceClusterId: string | null;
|
personId: string | null;
|
||||||
sourceType: SourceType;
|
sourceType: SourceType;
|
||||||
person?: ShallowDehydrateObject<Person> | null;
|
person?: ShallowDehydrateObject<Person> | null;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|||||||
@@ -67,8 +67,8 @@ export class PeopleUpdateItem extends PersonUpdateDto {
|
|||||||
id!: string;
|
id!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MergeFaceClusterDto {
|
export class MergePersonDto {
|
||||||
@ValidateUUID({ each: true, description: 'Face cluster IDs to merge' })
|
@ValidateUUID({ each: true, description: 'Person IDs to merge' })
|
||||||
ids!: string[];
|
ids!: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -411,18 +411,6 @@ export class SyncPersonV1 {
|
|||||||
faceAssetId!: string | null;
|
faceAssetId!: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExtraModel()
|
|
||||||
export class SyncPersonV2 extends SyncPersonV1 {
|
|
||||||
@ApiProperty({ description: 'Face cluster ID' })
|
|
||||||
faceClusterId!: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function syncPersonV2ToV1(personV2: SyncPersonV2): SyncPersonV1 {
|
|
||||||
const { faceClusterId: _, ...personV1 } = personV2;
|
|
||||||
|
|
||||||
return personV1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ExtraModel()
|
@ExtraModel()
|
||||||
export class SyncPersonDeleteV1 {
|
export class SyncPersonDeleteV1 {
|
||||||
@ApiProperty({ description: 'Person ID' })
|
@ApiProperty({ description: 'Person ID' })
|
||||||
@@ -461,40 +449,6 @@ export class SyncAssetFaceV2 extends SyncAssetFaceV1 {
|
|||||||
isVisible!: boolean;
|
isVisible!: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExtraModel()
|
|
||||||
export class SyncAssetFaceV3 {
|
|
||||||
@ApiProperty({ description: 'Asset face ID' })
|
|
||||||
id!: string;
|
|
||||||
@ApiProperty({ description: 'Asset ID' })
|
|
||||||
assetId!: string;
|
|
||||||
@ApiProperty({ description: 'Face cluster ID' })
|
|
||||||
faceClusterId!: string | null;
|
|
||||||
@ApiProperty({ type: 'integer' })
|
|
||||||
imageWidth!: number;
|
|
||||||
@ApiProperty({ type: 'integer' })
|
|
||||||
imageHeight!: number;
|
|
||||||
@ApiProperty({ type: 'integer' })
|
|
||||||
boundingBoxX1!: number;
|
|
||||||
@ApiProperty({ type: 'integer' })
|
|
||||||
boundingBoxY1!: number;
|
|
||||||
@ApiProperty({ type: 'integer' })
|
|
||||||
boundingBoxX2!: number;
|
|
||||||
@ApiProperty({ type: 'integer' })
|
|
||||||
boundingBoxY2!: number;
|
|
||||||
@ApiProperty({ description: 'Source type' })
|
|
||||||
sourceType!: string;
|
|
||||||
@ApiProperty({ description: 'Face deleted at' })
|
|
||||||
deletedAt!: Date | null;
|
|
||||||
@ApiProperty({ description: 'Is the face visible in the asset' })
|
|
||||||
isVisible!: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function syncAssetFaceV3ToV2(faceV3: SyncAssetFaceV3, personId: string | null): SyncAssetFaceV2 {
|
|
||||||
const { faceClusterId: _, ...face } = faceV3;
|
|
||||||
|
|
||||||
return { ...face, personId };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function syncAssetFaceV2ToV1(faceV2: SyncAssetFaceV2): SyncAssetFaceV1 {
|
export function syncAssetFaceV2ToV1(faceV2: SyncAssetFaceV2): SyncAssetFaceV1 {
|
||||||
const { deletedAt: _, isVisible: __, ...faceV1 } = faceV2;
|
const { deletedAt: _, isVisible: __, ...faceV1 } = faceV2;
|
||||||
|
|
||||||
@@ -507,24 +461,6 @@ export class SyncAssetFaceDeleteV1 {
|
|||||||
assetFaceId!: string;
|
assetFaceId!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExtraModel()
|
|
||||||
export class SyncFaceClusterV1 {
|
|
||||||
@ApiProperty({ description: 'Face cluster ID' })
|
|
||||||
id!: string;
|
|
||||||
@ApiProperty({ description: 'Created at' })
|
|
||||||
createdAt!: Date;
|
|
||||||
@ApiProperty({ description: 'Updated at' })
|
|
||||||
updatedAt!: Date;
|
|
||||||
@ApiProperty({ description: 'Owner ID' })
|
|
||||||
ownerId!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ExtraModel()
|
|
||||||
export class SyncFaceClusterDeleteV1 {
|
|
||||||
@ApiProperty({ description: 'Face cluster ID' })
|
|
||||||
faceClusterId!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ExtraModel()
|
@ExtraModel()
|
||||||
export class SyncUserMetadataV1 {
|
export class SyncUserMetadataV1 {
|
||||||
@ApiProperty({ description: 'User ID' })
|
@ApiProperty({ description: 'User ID' })
|
||||||
@@ -594,14 +530,10 @@ export type SyncItem = {
|
|||||||
[SyncEntityType.PartnerStackDeleteV1]: SyncStackDeleteV1;
|
[SyncEntityType.PartnerStackDeleteV1]: SyncStackDeleteV1;
|
||||||
[SyncEntityType.PartnerStackV1]: SyncStackV1;
|
[SyncEntityType.PartnerStackV1]: SyncStackV1;
|
||||||
[SyncEntityType.PersonV1]: SyncPersonV1;
|
[SyncEntityType.PersonV1]: SyncPersonV1;
|
||||||
[SyncEntityType.PersonV2]: SyncPersonV2;
|
|
||||||
[SyncEntityType.PersonDeleteV1]: SyncPersonDeleteV1;
|
[SyncEntityType.PersonDeleteV1]: SyncPersonDeleteV1;
|
||||||
[SyncEntityType.AssetFaceV1]: SyncAssetFaceV1;
|
[SyncEntityType.AssetFaceV1]: SyncAssetFaceV1;
|
||||||
[SyncEntityType.AssetFaceV2]: SyncAssetFaceV2;
|
[SyncEntityType.AssetFaceV2]: SyncAssetFaceV2;
|
||||||
[SyncEntityType.AssetFaceV3]: SyncAssetFaceV3;
|
|
||||||
[SyncEntityType.AssetFaceDeleteV1]: SyncAssetFaceDeleteV1;
|
[SyncEntityType.AssetFaceDeleteV1]: SyncAssetFaceDeleteV1;
|
||||||
[SyncEntityType.FaceClusterV1]: SyncFaceClusterV1;
|
|
||||||
[SyncEntityType.FaceClusterDeleteV1]: SyncFaceClusterDeleteV1;
|
|
||||||
[SyncEntityType.UserMetadataV1]: SyncUserMetadataV1;
|
[SyncEntityType.UserMetadataV1]: SyncUserMetadataV1;
|
||||||
[SyncEntityType.UserMetadataDeleteV1]: SyncUserMetadataDeleteV1;
|
[SyncEntityType.UserMetadataDeleteV1]: SyncUserMetadataDeleteV1;
|
||||||
[SyncEntityType.SyncAckV1]: SyncAckV1;
|
[SyncEntityType.SyncAckV1]: SyncAckV1;
|
||||||
|
|||||||
@@ -735,11 +735,8 @@ export enum SyncRequestType {
|
|||||||
StacksV1 = 'StacksV1',
|
StacksV1 = 'StacksV1',
|
||||||
UsersV1 = 'UsersV1',
|
UsersV1 = 'UsersV1',
|
||||||
PeopleV1 = 'PeopleV1',
|
PeopleV1 = 'PeopleV1',
|
||||||
PeopleV2 = 'PeopleV2',
|
|
||||||
AssetFacesV1 = 'AssetFacesV1',
|
AssetFacesV1 = 'AssetFacesV1',
|
||||||
AssetFacesV2 = 'AssetFacesV2',
|
AssetFacesV2 = 'AssetFacesV2',
|
||||||
AssetFacesV3 = 'AssetFacesV3',
|
|
||||||
FaceClusterV1 = 'FaceClusterV1',
|
|
||||||
UserMetadataV1 = 'UserMetadataV1',
|
UserMetadataV1 = 'UserMetadataV1',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,17 +794,12 @@ export enum SyncEntityType {
|
|||||||
StackDeleteV1 = 'StackDeleteV1',
|
StackDeleteV1 = 'StackDeleteV1',
|
||||||
|
|
||||||
PersonV1 = 'PersonV1',
|
PersonV1 = 'PersonV1',
|
||||||
PersonV2 = 'PersonV2',
|
|
||||||
PersonDeleteV1 = 'PersonDeleteV1',
|
PersonDeleteV1 = 'PersonDeleteV1',
|
||||||
|
|
||||||
AssetFaceV1 = 'AssetFaceV1',
|
AssetFaceV1 = 'AssetFaceV1',
|
||||||
AssetFaceV2 = 'AssetFaceV2',
|
AssetFaceV2 = 'AssetFaceV2',
|
||||||
AssetFaceV3 = 'AssetFaceV3',
|
|
||||||
AssetFaceDeleteV1 = 'AssetFaceDeleteV1',
|
AssetFaceDeleteV1 = 'AssetFaceDeleteV1',
|
||||||
|
|
||||||
FaceClusterV1 = 'FaceClusterV1',
|
|
||||||
FaceClusterDeleteV1 = 'FaceClusterDeleteV1',
|
|
||||||
|
|
||||||
UserMetadataV1 = 'UserMetadataV1',
|
UserMetadataV1 = 'UserMetadataV1',
|
||||||
UserMetadataDeleteV1 = 'UserMetadataDeleteV1',
|
UserMetadataDeleteV1 = 'UserMetadataDeleteV1',
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ export interface AssetFaceId {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateFacesData {
|
export interface UpdateFacesData {
|
||||||
oldFaceClusterId?: string;
|
oldPersonId?: string;
|
||||||
faceIds?: string[];
|
faceIds?: string[];
|
||||||
newFaceClusterId: string;
|
newPersonId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PersonStatistics {
|
export interface PersonStatistics {
|
||||||
@@ -54,7 +54,7 @@ export interface GetAllPeopleOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GetAllFacesOptions {
|
export interface GetAllFacesOptions {
|
||||||
faceClusterId?: string | null;
|
personId?: string | null;
|
||||||
assetId?: string;
|
assetId?: string;
|
||||||
sourceType?: SourceType;
|
sourceType?: SourceType;
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,7 @@ export type SelectFaceOptions = (keyof Selectable<AssetFaceTable>)[];
|
|||||||
|
|
||||||
const withPerson = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
|
const withPerson = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
|
||||||
return jsonObjectFrom(
|
return jsonObjectFrom(
|
||||||
eb.selectFrom('person').selectAll('person').whereRef('person.faceClusterId', '=', 'asset_face.faceClusterId'),
|
eb.selectFrom('person').selectAll('person').whereRef('person.id', '=', 'asset_face.personId'),
|
||||||
).as('person');
|
).as('person');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,11 +80,11 @@ export class PersonRepository {
|
|||||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||||
|
|
||||||
@GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] })
|
@GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] })
|
||||||
async reassignFaces({ oldFaceClusterId, faceIds, newFaceClusterId }: UpdateFacesData): Promise<number> {
|
async reassignFaces({ oldPersonId, faceIds, newPersonId }: UpdateFacesData): Promise<number> {
|
||||||
const result = await this.db
|
const result = await this.db
|
||||||
.updateTable('asset_face')
|
.updateTable('asset_face')
|
||||||
.set({ faceClusterId: newFaceClusterId })
|
.set({ personId: newPersonId })
|
||||||
.$if(!!oldFaceClusterId, (qb) => qb.where('asset_face.faceClusterId', '=', oldFaceClusterId!))
|
.$if(!!oldPersonId, (qb) => qb.where('asset_face.personId', '=', oldPersonId!))
|
||||||
.$if(!!faceIds, (qb) => qb.where('asset_face.id', 'in', faceIds!))
|
.$if(!!faceIds, (qb) => qb.where('asset_face.id', 'in', faceIds!))
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ export class PersonRepository {
|
|||||||
async unassignFaces({ sourceType }: UnassignFacesOptions): Promise<void> {
|
async unassignFaces({ sourceType }: UnassignFacesOptions): Promise<void> {
|
||||||
await this.db
|
await this.db
|
||||||
.updateTable('asset_face')
|
.updateTable('asset_face')
|
||||||
.set({ faceClusterId: null })
|
.set({ personId: null })
|
||||||
.where('asset_face.sourceType', '=', sourceType)
|
.where('asset_face.sourceType', '=', sourceType)
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
@@ -117,8 +117,8 @@ export class PersonRepository {
|
|||||||
return this.db
|
return this.db
|
||||||
.selectFrom('asset_face')
|
.selectFrom('asset_face')
|
||||||
.selectAll('asset_face')
|
.selectAll('asset_face')
|
||||||
.$if(options.faceClusterId === null, (qb) => qb.where('asset_face.faceClusterId', 'is', null))
|
.$if(options.personId === null, (qb) => qb.where('asset_face.personId', 'is', null))
|
||||||
.$if(!!options.faceClusterId, (qb) => qb.where('asset_face.faceClusterId', '=', options.faceClusterId!))
|
.$if(!!options.personId, (qb) => qb.where('asset_face.personId', '=', options.personId!))
|
||||||
.$if(!!options.sourceType, (qb) => qb.where('asset_face.sourceType', '=', options.sourceType!))
|
.$if(!!options.sourceType, (qb) => qb.where('asset_face.sourceType', '=', options.sourceType!))
|
||||||
.$if(!!options.assetId, (qb) => qb.where('asset_face.assetId', '=', options.assetId!))
|
.$if(!!options.assetId, (qb) => qb.where('asset_face.assetId', '=', options.assetId!))
|
||||||
.where('asset_face.deletedAt', 'is', null)
|
.where('asset_face.deletedAt', 'is', null)
|
||||||
@@ -153,7 +153,7 @@ export class PersonRepository {
|
|||||||
const items = await this.db
|
const items = await this.db
|
||||||
.selectFrom('person')
|
.selectFrom('person')
|
||||||
.selectAll('person')
|
.selectAll('person')
|
||||||
.innerJoin('asset_face', 'asset_face.faceClusterId', 'person.faceClusterId')
|
.innerJoin('asset_face', 'asset_face.personId', 'person.id')
|
||||||
.innerJoin('asset', (join) =>
|
.innerJoin('asset', (join) =>
|
||||||
join
|
join
|
||||||
.onRef('asset_face.assetId', '=', 'asset.id')
|
.onRef('asset_face.assetId', '=', 'asset.id')
|
||||||
@@ -209,7 +209,7 @@ export class PersonRepository {
|
|||||||
return this.db
|
return this.db
|
||||||
.selectFrom('person')
|
.selectFrom('person')
|
||||||
.selectAll('person')
|
.selectAll('person')
|
||||||
.leftJoin('asset_face', 'asset_face.faceClusterId', 'person.faceClusterId')
|
.leftJoin('asset_face', 'asset_face.personId', 'person.id')
|
||||||
.where('asset_face.deletedAt', 'is', null)
|
.where('asset_face.deletedAt', 'is', null)
|
||||||
.where('asset_face.isVisible', 'is', true)
|
.where('asset_face.isVisible', 'is', true)
|
||||||
.having((eb) => eb.fn.count('asset_face.assetId'), '=', 0)
|
.having((eb) => eb.fn.count('asset_face.assetId'), '=', 0)
|
||||||
@@ -248,7 +248,7 @@ export class PersonRepository {
|
|||||||
getFaceForFacialRecognitionJob(id: string) {
|
getFaceForFacialRecognitionJob(id: string) {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('asset_face')
|
.selectFrom('asset_face')
|
||||||
.select(['asset_face.id', 'asset_face.faceClusterId', 'asset_face.sourceType'])
|
.select(['asset_face.id', 'asset_face.personId', 'asset_face.sourceType'])
|
||||||
.select((eb) =>
|
.select((eb) =>
|
||||||
jsonObjectFrom(
|
jsonObjectFrom(
|
||||||
eb
|
eb
|
||||||
@@ -297,10 +297,10 @@ export class PersonRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
|
||||||
async reassignFace(assetFaceId: string, newFaceClusterId: string): Promise<number> {
|
async reassignFace(assetFaceId: string, newPersonId: string): Promise<number> {
|
||||||
const result = await this.db
|
const result = await this.db
|
||||||
.updateTable('asset_face')
|
.updateTable('asset_face')
|
||||||
.set({ faceClusterId: newFaceClusterId })
|
.set({ personId: newPersonId })
|
||||||
.where('asset_face.id', '=', assetFaceId)
|
.where('asset_face.id', '=', assetFaceId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
@@ -346,13 +346,13 @@ export class PersonRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
async getStatistics(faceClusterId: string): Promise<PersonStatistics> {
|
async getStatistics(personId: string): Promise<PersonStatistics> {
|
||||||
const result = await this.db
|
const result = await this.db
|
||||||
.selectFrom('asset_face')
|
.selectFrom('asset_face')
|
||||||
.leftJoin('asset', (join) =>
|
.leftJoin('asset', (join) =>
|
||||||
join
|
join
|
||||||
.onRef('asset.id', '=', 'asset_face.assetId')
|
.onRef('asset.id', '=', 'asset_face.assetId')
|
||||||
.on('asset_face.faceClusterId', '=', faceClusterId)
|
.on('asset_face.personId', '=', personId)
|
||||||
.on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
|
.on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
|
||||||
.on('asset.deletedAt', 'is', null),
|
.on('asset.deletedAt', 'is', null),
|
||||||
)
|
)
|
||||||
@@ -375,7 +375,7 @@ export class PersonRepository {
|
|||||||
eb.exists((eb) =>
|
eb.exists((eb) =>
|
||||||
eb
|
eb
|
||||||
.selectFrom('asset_face')
|
.selectFrom('asset_face')
|
||||||
.whereRef('asset_face.faceClusterId', '=', 'person.faceClusterId')
|
.whereRef('asset_face.personId', '=', 'person.id')
|
||||||
.where('asset_face.deletedAt', 'is', null)
|
.where('asset_face.deletedAt', 'is', null)
|
||||||
.where('asset_face.isVisible', '=', true)
|
.where('asset_face.isVisible', '=', true)
|
||||||
.where((eb) =>
|
.where((eb) =>
|
||||||
@@ -395,16 +395,7 @@ export class PersonRepository {
|
|||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(person: Insertable<PersonTable>) {
|
create(person: Insertable<PersonTable>) {
|
||||||
if (!person.faceClusterId) {
|
|
||||||
const { id } = await this.db
|
|
||||||
.insertInto('face_cluster')
|
|
||||||
.values({ ownerId: person.ownerId })
|
|
||||||
.returning('id')
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
person.faceClusterId = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow();
|
return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,19 +486,18 @@ export class PersonRepository {
|
|||||||
.selectFrom('asset_face')
|
.selectFrom('asset_face')
|
||||||
.selectAll('asset_face')
|
.selectAll('asset_face')
|
||||||
.select(withPerson)
|
.select(withPerson)
|
||||||
.innerJoin('person', (join) => join.onRef('person.faceClusterId', '=', 'asset_face.faceClusterId'))
|
|
||||||
.where('person.id', 'in', personIds)
|
|
||||||
.where('asset_face.assetId', 'in', assetIds)
|
.where('asset_face.assetId', 'in', assetIds)
|
||||||
|
.where('asset_face.personId', 'in', personIds)
|
||||||
.where('asset_face.deletedAt', 'is', null)
|
.where('asset_face.deletedAt', 'is', null)
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
getRandomFace(faceClusterId: string) {
|
getRandomFace(personId: string) {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('asset_face')
|
.selectFrom('asset_face')
|
||||||
.selectAll('asset_face')
|
.selectAll('asset_face')
|
||||||
.where('asset_face.faceClusterId', '=', faceClusterId)
|
.where('asset_face.personId', '=', personId)
|
||||||
.where('asset_face.deletedAt', 'is', null)
|
.where('asset_face.deletedAt', 'is', null)
|
||||||
.where('asset_face.isVisible', 'is', true)
|
.where('asset_face.isVisible', 'is', true)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
@@ -594,9 +584,7 @@ export class PersonRepository {
|
|||||||
.selectFrom('asset_face')
|
.selectFrom('asset_face')
|
||||||
.select('asset_face.id')
|
.select('asset_face.id')
|
||||||
.where('asset_face.assetId', '=', assetId)
|
.where('asset_face.assetId', '=', assetId)
|
||||||
.innerJoin('person', (join) =>
|
.where('asset_face.personId', '=', personId)
|
||||||
join.onRef('person.faceClusterId', '=', 'asset_face.faceClusterId').on('person.id', '=', personId),
|
|
||||||
)
|
|
||||||
.innerJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_face.assetId').on('asset.isOffline', '=', false))
|
.innerJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_face.assetId').on('asset.isOffline', '=', false))
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -338,15 +338,15 @@ export class SearchRepository {
|
|||||||
.selectFrom('asset_face')
|
.selectFrom('asset_face')
|
||||||
.select([
|
.select([
|
||||||
'asset_face.id',
|
'asset_face.id',
|
||||||
'asset_face.faceClusterId',
|
'asset_face.personId',
|
||||||
sql<number>`face_search.embedding <=> ${embedding}`.as('distance'),
|
sql<number>`face_search.embedding <=> ${embedding}`.as('distance'),
|
||||||
])
|
])
|
||||||
.innerJoin('asset', 'asset.id', 'asset_face.assetId')
|
.innerJoin('asset', 'asset.id', 'asset_face.assetId')
|
||||||
.innerJoin('face_search', 'face_search.faceId', 'asset_face.id')
|
.innerJoin('face_search', 'face_search.faceId', 'asset_face.id')
|
||||||
.leftJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId')
|
.leftJoin('person', 'person.id', 'asset_face.personId')
|
||||||
.where('asset.ownerId', '=', anyUuid(userIds))
|
.where('asset.ownerId', '=', anyUuid(userIds))
|
||||||
.where('asset.deletedAt', 'is', null)
|
.where('asset.deletedAt', 'is', null)
|
||||||
.$if(!!hasPerson, (qb) => qb.where('asset_face.faceClusterId', 'is not', null))
|
.$if(!!hasPerson, (qb) => qb.where('asset_face.personId', 'is not', null))
|
||||||
.$if(!!minBirthDate, (qb) =>
|
.$if(!!minBirthDate, (qb) =>
|
||||||
qb.where((eb) =>
|
qb.where((eb) =>
|
||||||
eb.or([eb('person.birthDate', 'is', null), eb('person.birthDate', '<=', minBirthDate!)]),
|
eb.or([eb('person.birthDate', 'is', null), eb('person.birthDate', '<=', minBirthDate!)]),
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ export class SyncRepository {
|
|||||||
assetFace: AssetFaceSync;
|
assetFace: AssetFaceSync;
|
||||||
assetMetadata: AssetMetadataSync;
|
assetMetadata: AssetMetadataSync;
|
||||||
authUser: AuthUserSync;
|
authUser: AuthUserSync;
|
||||||
faceCluster: FaceClusterSync;
|
|
||||||
memory: MemorySync;
|
memory: MemorySync;
|
||||||
memoryToAsset: MemoryToAssetSync;
|
memoryToAsset: MemoryToAssetSync;
|
||||||
partner: PartnerSync;
|
partner: PartnerSync;
|
||||||
@@ -81,7 +80,6 @@ export class SyncRepository {
|
|||||||
this.assetFace = new AssetFaceSync(this.db);
|
this.assetFace = new AssetFaceSync(this.db);
|
||||||
this.assetMetadata = new AssetMetadataSync(this.db);
|
this.assetMetadata = new AssetMetadataSync(this.db);
|
||||||
this.authUser = new AuthUserSync(this.db);
|
this.authUser = new AuthUserSync(this.db);
|
||||||
this.faceCluster = new FaceClusterSync(this.db);
|
|
||||||
this.memory = new MemorySync(this.db);
|
this.memory = new MemorySync(this.db);
|
||||||
this.memoryToAsset = new MemoryToAssetSync(this.db);
|
this.memoryToAsset = new MemoryToAssetSync(this.db);
|
||||||
this.partner = new PartnerSync(this.db);
|
this.partner = new PartnerSync(this.db);
|
||||||
@@ -449,7 +447,6 @@ class PersonSync extends BaseSync {
|
|||||||
'color',
|
'color',
|
||||||
'updateId',
|
'updateId',
|
||||||
'faceAssetId',
|
'faceAssetId',
|
||||||
'faceClusterId',
|
|
||||||
])
|
])
|
||||||
.where('ownerId', '=', options.userId)
|
.where('ownerId', '=', options.userId)
|
||||||
.stream();
|
.stream();
|
||||||
@@ -476,7 +473,7 @@ class AssetFaceSync extends BaseSync {
|
|||||||
.select([
|
.select([
|
||||||
'asset_face.id',
|
'asset_face.id',
|
||||||
'assetId',
|
'assetId',
|
||||||
'faceClusterId',
|
'personId',
|
||||||
'imageWidth',
|
'imageWidth',
|
||||||
'imageHeight',
|
'imageHeight',
|
||||||
'boundingBoxX1',
|
'boundingBoxX1',
|
||||||
@@ -488,8 +485,6 @@ class AssetFaceSync extends BaseSync {
|
|||||||
'asset_face.deletedAt',
|
'asset_face.deletedAt',
|
||||||
'asset_face.updateId',
|
'asset_face.updateId',
|
||||||
])
|
])
|
||||||
.leftJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId')
|
|
||||||
.select('person.id as personId')
|
|
||||||
.leftJoin('asset', 'asset.id', 'asset_face.assetId')
|
.leftJoin('asset', 'asset.id', 'asset_face.assetId')
|
||||||
.where('asset.ownerId', '=', options.userId)
|
.where('asset.ownerId', '=', options.userId)
|
||||||
.where('asset_face.isVisible', '=', true)
|
.where('asset_face.isVisible', '=', true)
|
||||||
@@ -497,35 +492,6 @@ class AssetFaceSync extends BaseSync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FaceClusterSync extends BaseSync {
|
|
||||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
|
||||||
getDeletes(options: SyncQueryOptions) {
|
|
||||||
return this.auditQuery('face_cluster_audit', options)
|
|
||||||
.select(['face_cluster_audit.id', 'face_cluster_audit.faceClusterId'])
|
|
||||||
.leftJoin('face_cluster', 'face_cluster.id', 'face_cluster_audit.id')
|
|
||||||
.where('face_cluster.ownerId', '=', options.userId)
|
|
||||||
.stream();
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanupAuditTable(daysAgo: number) {
|
|
||||||
return this.auditCleanup('face_cluster_audit', daysAgo);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
|
||||||
getUpserts(options: SyncQueryOptions) {
|
|
||||||
return this.upsertQuery('face_cluster', options)
|
|
||||||
.select([
|
|
||||||
'face_cluster.id',
|
|
||||||
'face_cluster.createdAt',
|
|
||||||
'face_cluster.updatedAt',
|
|
||||||
'face_cluster.ownerId',
|
|
||||||
'face_cluster.updateId',
|
|
||||||
])
|
|
||||||
.where('face_cluster.ownerId', '=', options.userId)
|
|
||||||
.stream();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AssetExifSync extends BaseSync {
|
class AssetExifSync extends BaseSync {
|
||||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||||
getUpserts(options: SyncQueryOptions) {
|
getUpserts(options: SyncQueryOptions) {
|
||||||
|
|||||||
@@ -299,16 +299,3 @@ export const asset_edit_audit = registerFunction({
|
|||||||
RETURN NULL;
|
RETURN NULL;
|
||||||
END`,
|
END`,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const face_cluster_delete_audit = registerFunction({
|
|
||||||
name: 'face_cluster_delete_audit',
|
|
||||||
returnType: 'TRIGGER',
|
|
||||||
language: 'PLPGSQL',
|
|
||||||
body: `
|
|
||||||
BEGIN
|
|
||||||
INSERT INTO face_cluster_audit ("faceClusterId", "ownerId")
|
|
||||||
SELECT "id", "ownerId"
|
|
||||||
FROM OLD;
|
|
||||||
RETURN NULL;
|
|
||||||
END`,
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table';
|
|||||||
import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table';
|
import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table';
|
||||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||||
import { AuditTable } from 'src/schema/tables/audit.table';
|
import { AuditTable } from 'src/schema/tables/audit.table';
|
||||||
import { FaceClusterAuditTable } from 'src/schema/tables/face-cluster-audit.table';
|
|
||||||
import { FaceClusterTable } from 'src/schema/tables/face-cluster.table';
|
|
||||||
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
|
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
|
||||||
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
|
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
|
||||||
import { LibraryTable } from 'src/schema/tables/library.table';
|
import { LibraryTable } from 'src/schema/tables/library.table';
|
||||||
@@ -201,8 +199,6 @@ export interface DB {
|
|||||||
|
|
||||||
audit: AuditTable;
|
audit: AuditTable;
|
||||||
|
|
||||||
face_cluster: FaceClusterTable;
|
|
||||||
face_cluster_audit: FaceClusterAuditTable;
|
|
||||||
face_search: FaceSearchTable;
|
face_search: FaceSearchTable;
|
||||||
|
|
||||||
geodata_places: GeodataPlacesTable;
|
geodata_places: GeodataPlacesTable;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { SourceType } from 'src/enum';
|
|||||||
import { asset_face_source_type } from 'src/schema/enums';
|
import { asset_face_source_type } from 'src/schema/enums';
|
||||||
import { asset_face_audit } from 'src/schema/functions';
|
import { asset_face_audit } from 'src/schema/functions';
|
||||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||||
import { FaceClusterTable } from 'src/schema/tables/face-cluster.table';
|
import { PersonTable } from 'src/schema/tables/person.table';
|
||||||
|
|
||||||
@Table({ name: 'asset_face' })
|
@Table({ name: 'asset_face' })
|
||||||
@UpdatedAtTrigger('asset_face_updatedAt')
|
@UpdatedAtTrigger('asset_face_updatedAt')
|
||||||
@@ -26,13 +26,13 @@ import { FaceClusterTable } from 'src/schema/tables/face-cluster.table';
|
|||||||
when: 'pg_trigger_depth() = 0',
|
when: 'pg_trigger_depth() = 0',
|
||||||
})
|
})
|
||||||
// schemaFromDatabase does not preserve column order
|
// schemaFromDatabase does not preserve column order
|
||||||
@Index({ name: 'asset_face_assetId_faceClusterId_idx', columns: ['assetId', 'faceClusterId'] })
|
@Index({ name: 'asset_face_assetId_personId_idx', columns: ['assetId', 'personId'] })
|
||||||
@Index({
|
@Index({
|
||||||
name: 'asset_face_faceClusterId_assetId_notDeleted_isVisible_idx',
|
name: 'asset_face_personId_assetId_notDeleted_isVisible_idx',
|
||||||
columns: ['faceClusterId', 'assetId'],
|
columns: ['personId', 'assetId'],
|
||||||
where: '"deletedAt" IS NULL AND "isVisible" IS TRUE',
|
where: '"deletedAt" IS NULL AND "isVisible" IS TRUE',
|
||||||
})
|
})
|
||||||
@Index({ columns: ['faceClusterId', 'assetId'] })
|
@Index({ columns: ['personId', 'assetId'] })
|
||||||
export class AssetFaceTable {
|
export class AssetFaceTable {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id!: Generated<string>;
|
id!: Generated<string>;
|
||||||
@@ -45,14 +45,14 @@ export class AssetFaceTable {
|
|||||||
})
|
})
|
||||||
assetId!: string;
|
assetId!: string;
|
||||||
|
|
||||||
@ForeignKeyColumn(() => FaceClusterTable, {
|
@ForeignKeyColumn(() => PersonTable, {
|
||||||
onDelete: 'SET NULL',
|
onDelete: 'SET NULL',
|
||||||
onUpdate: 'CASCADE',
|
onUpdate: 'CASCADE',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
// [faceClusterId, assetId] makes this redundant
|
// [personId, assetId] makes this redundant
|
||||||
index: false,
|
index: false,
|
||||||
})
|
})
|
||||||
faceClusterId!: string | null;
|
personId!: string | null;
|
||||||
|
|
||||||
@Column({ default: 0, type: 'integer' })
|
@Column({ default: 0, type: 'integer' })
|
||||||
imageWidth!: Generated<number>;
|
imageWidth!: Generated<number>;
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
|
|
||||||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
|
||||||
|
|
||||||
@Table('face_cluster_audit')
|
|
||||||
export class FaceClusterAuditTable {
|
|
||||||
@PrimaryGeneratedUuidV7Column()
|
|
||||||
id!: Generated<string>;
|
|
||||||
|
|
||||||
@Column({ type: 'uuid', index: true })
|
|
||||||
faceClusterId!: string;
|
|
||||||
|
|
||||||
@Column({ type: 'uuid', index: true })
|
|
||||||
ownerId!: string;
|
|
||||||
|
|
||||||
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
|
|
||||||
deletedAt!: Generated<Timestamp>;
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import {
|
|
||||||
AfterDeleteTrigger,
|
|
||||||
CreateDateColumn,
|
|
||||||
ForeignKeyColumn,
|
|
||||||
Generated,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
Table,
|
|
||||||
Timestamp,
|
|
||||||
UpdateDateColumn,
|
|
||||||
} from '@immich/sql-tools';
|
|
||||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
|
||||||
import { face_cluster_delete_audit } from 'src/schema/functions';
|
|
||||||
import { UserTable } from 'src/schema/tables/user.table';
|
|
||||||
|
|
||||||
@Table('face_cluster')
|
|
||||||
@UpdatedAtTrigger('face_cluster_updatedAt')
|
|
||||||
@AfterDeleteTrigger({
|
|
||||||
scope: 'statement',
|
|
||||||
function: face_cluster_delete_audit,
|
|
||||||
referencingOldTableAs: 'old',
|
|
||||||
when: 'pg_trigger_depth() = 0',
|
|
||||||
})
|
|
||||||
export class FaceClusterTable {
|
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id!: Generated<string>;
|
|
||||||
|
|
||||||
@CreateDateColumn()
|
|
||||||
createdAt!: Generated<Timestamp>;
|
|
||||||
|
|
||||||
@UpdateDateColumn()
|
|
||||||
updatedAt!: Generated<Timestamp>;
|
|
||||||
|
|
||||||
@UpdateIdColumn({ index: true })
|
|
||||||
updateId!: Generated<string>;
|
|
||||||
|
|
||||||
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
|
|
||||||
ownerId!: string;
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||||
import { person_delete_audit } from 'src/schema/functions';
|
import { person_delete_audit } from 'src/schema/functions';
|
||||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||||
import { FaceClusterTable } from 'src/schema/tables/face-cluster.table';
|
|
||||||
import { UserTable } from 'src/schema/tables/user.table';
|
import { UserTable } from 'src/schema/tables/user.table';
|
||||||
|
|
||||||
@Table('person')
|
@Table('person')
|
||||||
@@ -61,7 +60,4 @@ export class PersonTable {
|
|||||||
|
|
||||||
@UpdateIdColumn({ index: true })
|
@UpdateIdColumn({ index: true })
|
||||||
updateId!: Generated<string>;
|
updateId!: Generated<string>;
|
||||||
|
|
||||||
@ForeignKeyColumn(() => FaceClusterTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true, index: true })
|
|
||||||
faceClusterId!: string | null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -876,7 +876,7 @@ export class MetadataService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
this.logger.debug(`Creating missing people: ${missing.map((p) => `${p.name}/${p.id}`)}`);
|
this.logger.debugFn(() => `Creating missing persons: ${missing.map((p) => `${p.name}/${p.id}`)}`);
|
||||||
const newPersonIds = await this.personRepository.createAll(missing);
|
const newPersonIds = await this.personRepository.createAll(missing);
|
||||||
const jobs = newPersonIds.map((id) => ({ name: JobName.PersonGenerateThumbnail, data: { id } }) as const);
|
const jobs = newPersonIds.map((id) => ({ name: JobName.PersonGenerateThumbnail, data: { id } }) as const);
|
||||||
await this.jobRepository.queueAll(jobs);
|
await this.jobRepository.queueAll(jobs);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
FaceDto,
|
FaceDto,
|
||||||
mapFaces,
|
mapFaces,
|
||||||
mapPerson,
|
mapPerson,
|
||||||
MergeFaceClusterDto,
|
MergePersonDto,
|
||||||
PeopleResponseDto,
|
PeopleResponseDto,
|
||||||
PeopleUpdateDto,
|
PeopleUpdateDto,
|
||||||
PersonCreateDto,
|
PersonCreateDto,
|
||||||
@@ -438,7 +438,7 @@ export class PersonService extends BaseService {
|
|||||||
|
|
||||||
const lastRun = new Date().toISOString();
|
const lastRun = new Date().toISOString();
|
||||||
const facePagination = this.personRepository.getAllFaces(
|
const facePagination = this.personRepository.getAllFaces(
|
||||||
force ? undefined : { faceClusterId: null, sourceType: SourceType.MachineLearning },
|
force ? undefined : { personId: null, sourceType: SourceType.MachineLearning },
|
||||||
);
|
);
|
||||||
|
|
||||||
let jobs: { name: JobName.FacialRecognition; data: { id: string; deferred: false } }[] = [];
|
let jobs: { name: JobName.FacialRecognition; data: { id: string; deferred: false } }[] = [];
|
||||||
@@ -481,8 +481,8 @@ export class PersonService extends BaseService {
|
|||||||
return JobStatus.Failed;
|
return JobStatus.Failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (face.faceClusterId) {
|
if (face.personId) {
|
||||||
this.logger.debug(`Face ${id} already belongs to a face cluster`);
|
this.logger.debug(`Face ${id} already has a person assigned`);
|
||||||
return JobStatus.Skipped;
|
return JobStatus.Skipped;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,8 +511,8 @@ export class PersonService extends BaseService {
|
|||||||
return JobStatus.Skipped;
|
return JobStatus.Skipped;
|
||||||
}
|
}
|
||||||
|
|
||||||
let faceClusterId = matches.find((match) => match.faceClusterId)?.faceClusterId;
|
let personId = matches.find((match) => match.personId)?.personId;
|
||||||
if (!faceClusterId) {
|
if (!personId) {
|
||||||
const matchWithPerson = await this.searchRepository.searchFaces({
|
const matchWithPerson = await this.searchRepository.searchFaces({
|
||||||
userIds: [face.asset.ownerId],
|
userIds: [face.asset.ownerId],
|
||||||
embedding: face.faceSearch.embedding,
|
embedding: face.faceSearch.embedding,
|
||||||
@@ -523,20 +523,20 @@ export class PersonService extends BaseService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (matchWithPerson.length > 0) {
|
if (matchWithPerson.length > 0) {
|
||||||
faceClusterId = matchWithPerson[0].faceClusterId;
|
personId = matchWithPerson[0].personId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCore && !faceClusterId) {
|
if (isCore && !personId) {
|
||||||
this.logger.log(`Creating new person for face ${id}`);
|
this.logger.log(`Creating new person for face ${id}`);
|
||||||
const newPerson = await this.personRepository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id });
|
const newPerson = await this.personRepository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id });
|
||||||
await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id: newPerson.id } });
|
await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id: newPerson.id } });
|
||||||
faceClusterId = newPerson.faceClusterId;
|
personId = newPerson.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (faceClusterId) {
|
if (personId) {
|
||||||
this.logger.debug(`Assigning face ${id} to face cluster ${faceClusterId}`);
|
this.logger.debug(`Assigning face ${id} to person ${personId}`);
|
||||||
await this.personRepository.reassignFaces({ faceIds: [id], newFaceClusterId: faceClusterId });
|
await this.personRepository.reassignFaces({ faceIds: [id], newPersonId: personId });
|
||||||
}
|
}
|
||||||
|
|
||||||
return JobStatus.Success;
|
return JobStatus.Success;
|
||||||
@@ -554,7 +554,7 @@ export class PersonService extends BaseService {
|
|||||||
return JobStatus.Success;
|
return JobStatus.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
async mergePerson(auth: AuthDto, id: string, dto: MergeFaceClusterDto): Promise<BulkIdResponseDto[]> {
|
async mergePerson(auth: AuthDto, id: string, dto: MergePersonDto): Promise<BulkIdResponseDto[]> {
|
||||||
const mergeIds = dto.ids;
|
const mergeIds = dto.ids;
|
||||||
if (mergeIds.includes(id)) {
|
if (mergeIds.includes(id)) {
|
||||||
throw new BadRequestException('Cannot merge a person into themselves');
|
throw new BadRequestException('Cannot merge a person into themselves');
|
||||||
@@ -600,7 +600,7 @@ export class PersonService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mergeName = mergePerson.name || mergePerson.id;
|
const mergeName = mergePerson.name || mergePerson.id;
|
||||||
const mergeData: UpdateFacesData = { oldFaceClusterId: mergeId, newFaceClusterId: id };
|
const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id };
|
||||||
this.logger.log(`Merging ${mergeName} into ${primaryName}`);
|
this.logger.log(`Merging ${mergeName} into ${primaryName}`);
|
||||||
|
|
||||||
await this.personRepository.reassignFaces(mergeData);
|
await this.personRepository.reassignFaces(mergeData);
|
||||||
@@ -678,14 +678,8 @@ export class PersonService extends BaseService {
|
|||||||
dto.imageHeight = originalDimensions.height;
|
dto.imageHeight = originalDimensions.height;
|
||||||
}
|
}
|
||||||
|
|
||||||
const person = await this.personRepository.getById(dto.personId);
|
|
||||||
|
|
||||||
if (!person?.faceClusterId) {
|
|
||||||
throw new Error('Person must already have some recognized faces and belong to a face cluster');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.personRepository.createAssetFace({
|
await this.personRepository.createAssetFace({
|
||||||
faceClusterId: person.faceClusterId,
|
personId: dto.personId,
|
||||||
assetId: dto.assetId,
|
assetId: dto.assetId,
|
||||||
imageHeight: dto.imageHeight,
|
imageHeight: dto.imageHeight,
|
||||||
imageWidth: dto.imageWidth,
|
imageWidth: dto.imageWidth,
|
||||||
|
|||||||
@@ -13,10 +13,8 @@ import {
|
|||||||
SyncAckDeleteDto,
|
SyncAckDeleteDto,
|
||||||
SyncAckSetDto,
|
SyncAckSetDto,
|
||||||
syncAssetFaceV2ToV1,
|
syncAssetFaceV2ToV1,
|
||||||
syncAssetFaceV3ToV2,
|
|
||||||
SyncAssetV1,
|
SyncAssetV1,
|
||||||
SyncItem,
|
SyncItem,
|
||||||
syncPersonV2ToV1,
|
|
||||||
SyncStreamDto,
|
SyncStreamDto,
|
||||||
} from 'src/dtos/sync.dto';
|
} from 'src/dtos/sync.dto';
|
||||||
import {
|
import {
|
||||||
@@ -194,11 +192,8 @@ export class SyncService extends BaseService {
|
|||||||
[SyncRequestType.StacksV1]: () => this.syncStackV1(options, response, checkpointMap),
|
[SyncRequestType.StacksV1]: () => this.syncStackV1(options, response, checkpointMap),
|
||||||
[SyncRequestType.PartnerStacksV1]: () => this.syncPartnerStackV1(options, response, checkpointMap, session.id),
|
[SyncRequestType.PartnerStacksV1]: () => this.syncPartnerStackV1(options, response, checkpointMap, session.id),
|
||||||
[SyncRequestType.PeopleV1]: () => this.syncPeopleV1(options, response, checkpointMap),
|
[SyncRequestType.PeopleV1]: () => this.syncPeopleV1(options, response, checkpointMap),
|
||||||
[SyncRequestType.PeopleV2]: () => this.syncPeopleV2(options, response, checkpointMap),
|
|
||||||
[SyncRequestType.AssetFacesV1]: async () => this.syncAssetFacesV1(options, response, checkpointMap),
|
[SyncRequestType.AssetFacesV1]: async () => this.syncAssetFacesV1(options, response, checkpointMap),
|
||||||
[SyncRequestType.AssetFacesV2]: async () => this.syncAssetFacesV2(options, response, checkpointMap),
|
[SyncRequestType.AssetFacesV2]: async () => this.syncAssetFacesV2(options, response, checkpointMap),
|
||||||
[SyncRequestType.AssetFacesV3]: async () => this.syncAssetFacesV3(options, response, checkpointMap),
|
|
||||||
[SyncRequestType.FaceClusterV1]: async () => this.syncFaceClusterV1(options, response, checkpointMap),
|
|
||||||
[SyncRequestType.UserMetadataV1]: () => this.syncUserMetadataV1(options, response, checkpointMap),
|
[SyncRequestType.UserMetadataV1]: () => this.syncUserMetadataV1(options, response, checkpointMap),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -801,20 +796,6 @@ export class SyncService extends BaseService {
|
|||||||
|
|
||||||
const upsertType = SyncEntityType.PersonV1;
|
const upsertType = SyncEntityType.PersonV1;
|
||||||
const upserts = this.syncRepository.person.getUpserts({ ...options, ack: checkpointMap[upsertType] });
|
const upserts = this.syncRepository.person.getUpserts({ ...options, ack: checkpointMap[upsertType] });
|
||||||
for await (const { updateId, ...data } of upserts) {
|
|
||||||
send(response, { type: upsertType, ids: [updateId], data: syncPersonV2ToV1(data) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async syncPeopleV2(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
|
|
||||||
const deleteType = SyncEntityType.PersonDeleteV1;
|
|
||||||
const deletes = this.syncRepository.person.getDeletes({ ...options, ack: checkpointMap[deleteType] });
|
|
||||||
for await (const { id, ...data } of deletes) {
|
|
||||||
send(response, { type: deleteType, ids: [id], data });
|
|
||||||
}
|
|
||||||
|
|
||||||
const upsertType = SyncEntityType.PersonV2;
|
|
||||||
const upserts = this.syncRepository.person.getUpserts({ ...options, ack: checkpointMap[upsertType] });
|
|
||||||
for await (const { updateId, ...data } of upserts) {
|
for await (const { updateId, ...data } of upserts) {
|
||||||
send(response, { type: upsertType, ids: [updateId], data });
|
send(response, { type: upsertType, ids: [updateId], data });
|
||||||
}
|
}
|
||||||
@@ -829,8 +810,8 @@ export class SyncService extends BaseService {
|
|||||||
|
|
||||||
const upsertType = SyncEntityType.AssetFaceV1;
|
const upsertType = SyncEntityType.AssetFaceV1;
|
||||||
const upserts = this.syncRepository.assetFace.getUpserts({ ...options, ack: checkpointMap[upsertType] });
|
const upserts = this.syncRepository.assetFace.getUpserts({ ...options, ack: checkpointMap[upsertType] });
|
||||||
for await (const { updateId, personId, ...data } of upserts) {
|
for await (const { updateId, ...data } of upserts) {
|
||||||
const v1 = syncAssetFaceV2ToV1(syncAssetFaceV3ToV2(data, personId));
|
const v1 = syncAssetFaceV2ToV1(data);
|
||||||
send(response, { type: upsertType, ids: [updateId], data: v1 });
|
send(response, { type: upsertType, ids: [updateId], data: v1 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -844,34 +825,6 @@ export class SyncService extends BaseService {
|
|||||||
|
|
||||||
const upsertType = SyncEntityType.AssetFaceV2;
|
const upsertType = SyncEntityType.AssetFaceV2;
|
||||||
const upserts = this.syncRepository.assetFace.getUpserts({ ...options, ack: checkpointMap[upsertType] });
|
const upserts = this.syncRepository.assetFace.getUpserts({ ...options, ack: checkpointMap[upsertType] });
|
||||||
for await (const { updateId, personId, ...data } of upserts) {
|
|
||||||
send(response, { type: upsertType, ids: [updateId], data: syncAssetFaceV3ToV2(data, personId) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async syncAssetFacesV3(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
|
|
||||||
const deleteType = SyncEntityType.AssetFaceDeleteV1;
|
|
||||||
const deletes = this.syncRepository.assetFace.getDeletes({ ...options, ack: checkpointMap[deleteType] });
|
|
||||||
for await (const { id, ...data } of deletes) {
|
|
||||||
send(response, { type: deleteType, ids: [id], data });
|
|
||||||
}
|
|
||||||
|
|
||||||
const upsertType = SyncEntityType.AssetFaceV3;
|
|
||||||
const upserts = this.syncRepository.assetFace.getUpserts({ ...options, ack: checkpointMap[upsertType] });
|
|
||||||
for await (const { updateId, personId: _, ...data } of upserts) {
|
|
||||||
send(response, { type: upsertType, ids: [updateId], data });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async syncFaceClusterV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
|
|
||||||
const deleteType = SyncEntityType.FaceClusterDeleteV1;
|
|
||||||
const deletes = this.syncRepository.faceCluster.getDeletes({ ...options, ack: checkpointMap[deleteType] });
|
|
||||||
for await (const { id, ...data } of deletes) {
|
|
||||||
send(response, { type: deleteType, ids: [id], data });
|
|
||||||
}
|
|
||||||
|
|
||||||
const upsertType = SyncEntityType.FaceClusterV1;
|
|
||||||
const upserts = this.syncRepository.faceCluster.getUpserts({ ...options, ack: checkpointMap[upsertType] });
|
|
||||||
for await (const { updateId, ...data } of upserts) {
|
for await (const { updateId, ...data } of upserts) {
|
||||||
send(response, { type: upsertType, ids: [updateId], data });
|
send(response, { type: upsertType, ids: [updateId], data });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -144,12 +144,7 @@ export function withFacesAndPeople(
|
|||||||
.selectFrom('asset_face')
|
.selectFrom('asset_face')
|
||||||
.leftJoinLateral(
|
.leftJoinLateral(
|
||||||
(eb) =>
|
(eb) =>
|
||||||
eb
|
eb.selectFrom('person').selectAll('person').whereRef('asset_face.personId', '=', 'person.id').as('person'),
|
||||||
.selectFrom('face_cluster')
|
|
||||||
.where('face_cluster.id', '=', 'asset_face.faceClusterId')
|
|
||||||
.innerJoin('person', 'person.faceClusterId', 'face_cluster.id')
|
|
||||||
.selectAll('person')
|
|
||||||
.as('person'),
|
|
||||||
(join) => join.onTrue(),
|
(join) => join.onTrue(),
|
||||||
)
|
)
|
||||||
.selectAll('asset_face')
|
.selectAll('asset_face')
|
||||||
@@ -166,12 +161,11 @@ export function hasPeople<O>(qb: SelectQueryBuilder<DB, 'asset', O>, personIds:
|
|||||||
eb
|
eb
|
||||||
.selectFrom('asset_face')
|
.selectFrom('asset_face')
|
||||||
.select('assetId')
|
.select('assetId')
|
||||||
.innerJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId')
|
.where('personId', '=', anyUuid(personIds!))
|
||||||
.where('person.id', '=', anyUuid(personIds!))
|
|
||||||
.where('deletedAt', 'is', null)
|
.where('deletedAt', 'is', null)
|
||||||
.where('isVisible', 'is', true)
|
.where('isVisible', 'is', true)
|
||||||
.groupBy('assetId')
|
.groupBy('assetId')
|
||||||
.having((eb) => eb.fn.count('person.id').distinct(), '=', personIds.length)
|
.having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length)
|
||||||
.as('has_people'),
|
.as('has_people'),
|
||||||
(join) => join.onRef('has_people.assetId', '=', 'asset.id'),
|
(join) => join.onRef('has_people.assetId', '=', 'asset.id'),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
+6
-6
@@ -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": {
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -28,10 +28,7 @@
|
|||||||
let { onClose }: Props = $props();
|
let { onClose }: Props = $props();
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// TODO the server should *really* just return all albums (paginated ideally)
|
albums = await getAllAlbums({});
|
||||||
const ownedAlbums = await getAllAlbums({ shared: false });
|
|
||||||
ownedAlbums.push.apply(ownedAlbums, await getAllAlbums({ shared: true }));
|
|
||||||
albums = ownedAlbums;
|
|
||||||
recentAlbums = albums.sort((a, b) => (new Date(a.updatedAt) > new Date(b.updatedAt) ? -1 : 1)).slice(0, 3);
|
recentAlbums = albums.sort((a, b) => (new Date(a.updatedAt) > new Date(b.updatedAt) ? -1 : 1)).slice(0, 3);
|
||||||
loading = false;
|
loading = false;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,16 +8,17 @@ import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
|||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { user as authUser, preferences } from '$lib/stores/user.store';
|
import { user as authUser, preferences } from '$lib/stores/user.store';
|
||||||
import type { AssetControlContext } from '$lib/types';
|
import type { AssetControlContext } from '$lib/types';
|
||||||
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
|
import { getSharedLink, sleep } from '$lib/utils';
|
||||||
import { downloadUrl } from '$lib/utils/asset-utils';
|
import { downloadUrl } from '$lib/utils/asset-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { getFormatter } from '$lib/utils/i18n';
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
|
import { asQueryString } from '$lib/utils/shared-links';
|
||||||
import {
|
import {
|
||||||
AssetJobName,
|
AssetJobName,
|
||||||
AssetMediaSize,
|
|
||||||
AssetTypeEnum,
|
AssetTypeEnum,
|
||||||
AssetVisibility,
|
AssetVisibility,
|
||||||
getAssetInfo,
|
getAssetInfo,
|
||||||
|
getBaseUrl,
|
||||||
runAssetJobs,
|
runAssetJobs,
|
||||||
updateAsset,
|
updateAsset,
|
||||||
type AssetJobsDto,
|
type AssetJobsDto,
|
||||||
@@ -307,7 +308,6 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
|
|||||||
{
|
{
|
||||||
filename: asset.originalFileName,
|
filename: asset.originalFileName,
|
||||||
id: asset.id,
|
id: asset.id,
|
||||||
cacheKey: asset.thumbhash,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -321,12 +321,13 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
|
|||||||
assets.push({
|
assets.push({
|
||||||
filename: motionAsset.originalFileName,
|
filename: motionAsset.originalFileName,
|
||||||
id: asset.livePhotoVideoId,
|
id: asset.livePhotoVideoId,
|
||||||
cacheKey: motionAsset.thumbhash,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [i, { filename, id, cacheKey }] of assets.entries()) {
|
const queryParams = asQueryString(authManager.params);
|
||||||
|
|
||||||
|
for (const [i, { filename, id }] of assets.entries()) {
|
||||||
if (i !== 0) {
|
if (i !== 0) {
|
||||||
// play nice with Safari
|
// play nice with Safari
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
@@ -334,7 +335,12 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
toastManager.primary($t('downloading_asset_filename', { values: { filename } }));
|
toastManager.primary($t('downloading_asset_filename', { values: { filename } }));
|
||||||
downloadUrl(getAssetMediaUrl({ id, size: AssetMediaSize.Original, edited, cacheKey }), filename);
|
downloadUrl(
|
||||||
|
getBaseUrl() +
|
||||||
|
`/assets/${id}/original` +
|
||||||
|
(queryParams ? `?${queryParams}&edited=${edited}` : `?edited=${edited}`),
|
||||||
|
filename,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.error_downloading', { values: { filename } }));
|
handleError(error, $t('errors.error_downloading', { values: { filename } }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export const isFaceEditMode = $state({ value: false });
|
export const isFaceEditMode = $state({ value: false });
|
||||||
|
export const isEditFacesPanelOpen = $state({ value: false });
|
||||||
|
|||||||
@@ -80,34 +80,7 @@ function createUploadStore() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removeItem = (id: string) => {
|
const removeItem = (id: string) => {
|
||||||
uploadAssets.update((uploadingAsset) => {
|
uploadAssets.update((uploadingAsset) => uploadingAsset.filter((a) => a.id != id));
|
||||||
const assetToRemove = uploadingAsset.find((a) => a.id === id);
|
|
||||||
if (assetToRemove) {
|
|
||||||
stats.update((stats) => {
|
|
||||||
switch (assetToRemove.state) {
|
|
||||||
case UploadState.DONE: {
|
|
||||||
stats.success--;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case UploadState.DUPLICATED: {
|
|
||||||
stats.duplicates--;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case UploadState.ERROR: {
|
|
||||||
stats.errors--;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.total--;
|
|
||||||
return stats;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return uploadingAsset.filter((a) => a.id != id);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const dismissErrors = () =>
|
const dismissErrors = () =>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user