Compare commits

..

12 Commits

Author SHA1 Message Date
Claude b374d7eb87 fix: resolve lint and formatting failures
- Fix ESLint errors in compare.ts: remove unused params, use toSorted(),
  restructure negated conditions, move imgTag to module scope, replace
  process.exit with throw
- Fix ESLint errors in analyze-deps.ts: use toSorted(), wrap callback
  in arrow function, replace process.exit with throw
- Run Prettier on compare.ts, run-scenarios.ts, and navigation-bar.svelte

https://claude.ai/code/session_01XSTqDJXuR4jaLN7SGm3uES
2026-03-01 22:22:33 +00:00
Claude d20def9f66 fix: base screenshots + switch to artifact-based image hosting
- Fix blank base screenshots by aggressively killing the preview server
  between PR and base steps (fuser -k on port 4173) and clearing the
  SvelteKit build cache before rebuilding the base version
- Replace git branch push with GitHub Actions artifact upload:
  - Upload full screenshots as a zipped artifact for download
  - Upload an HTML report with embedded base64 images as a non-zipped
    artifact (archive: false) for direct browser viewing
- Update compare.ts to generate both a text-only markdown summary
  (for the PR comment) and a self-contained HTML visual comparison
- Downgrade permissions from contents:write to contents:read since
  we no longer push to the repository

https://claude.ai/code/session_01XSTqDJXuR4jaLN7SGm3uES
2026-03-01 20:38:58 +00:00
Claude 13e8a0121f fix: disable pnpm verifyDepsBeforeRun for base steps and restore PR source
The workspace has verifyDepsBeforeRun: install which triggers pnpm install
before every pnpm exec/run. After checking out the base web/SDK code, the
package.json files no longer match the lockfile, causing pnpm to re-install
and corrupt the workspace state. This broke both the base build and the
compare step.

- Set PNPM_VERIFY_DEPS_BEFORE_RUN=false on all steps after the base checkout
- Add a "Restore PR source" step to reset web/SDK to the PR version before
  running compare, so subsequent pnpm commands work normally

https://claude.ai/code/session_01XSTqDJXuR4jaLN7SGm3uES
2026-03-01 19:59:29 +00:00
Claude fe4c0a95d5 fix: use pnpm build without install for base and ensure dirs exist
- Remove pnpm install from base build steps since it modifies the workspace
  lockfile and can break tsx/other deps used by later steps
- Just run pnpm build using the existing node_modules from the PR install
- Ensure screenshot directories exist before compare step runs

https://claude.ai/code/session_01XSTqDJXuR4jaLN7SGm3uES
2026-03-01 19:52:59 +00:00
Claude c5abd18a64 fix: handle base build failures and add resilient API mocking
- Drop --frozen-lockfile for base builds since the PR's lockfile may not
  match the base branch's package.json
- Add continue-on-error and step dependencies so the workflow continues
  gracefully when base builds fail
- Add catch-all /api/** mock to return empty JSON for unmocked endpoints
- Block socket.io WebSocket connections that prevent networkidle
- Add networkidle timeout fallback in screenshot runner

https://claude.ai/code/session_01XSTqDJXuR4jaLN7SGm3uES
2026-03-01 19:52:59 +00:00
Claude cf1a9ed3f5 fix: add catch-all API mock and socket.io block for base screenshots
Base screenshots showed loading spinners or were missing entirely because:
1. Unmocked API calls (e.g. /api/people, /api/search/explore) hit the static
   preview server which returns HTML instead of JSON, preventing networkidle
2. Socket.io WebSocket connections never complete handshake, blocking networkidle

Add a catch-all /api/** mock (registered first, so specific mocks take priority)
that returns empty JSON for any unmocked endpoint. Block socket.io connections.
Also add a networkidle timeout fallback in run-scenarios.ts so screenshots are
still captured even if networkidle doesn't resolve within 15s.

https://claude.ai/code/session_01XSTqDJXuR4jaLN7SGm3uES
2026-03-01 19:52:59 +00:00
Claude feacf9b134 fix: improve screenshot waiting, add inline images to PR comments
- Increase waitForSelector timeout from 5s to 15s for slower page loads
- Add explicit wait for loading spinners to disappear before screenshot
- Push screenshot images to a temporary branch for inline display
- Read report.md from compare.ts with raw.githubusercontent.com URLs
- Accept optional image base URL in compare.ts CLI
- Upgrade contents permission to write for pushing screenshot branch

https://claude.ai/code/session_01XSTqDJXuR4jaLN7SGm3uES
2026-03-01 19:52:59 +00:00
Claude 67eb33b3a7 fix: use GitHub API for changed files detection and replace add-pr-comment
- Use pulls.listFiles API instead of git diff to detect changed web files
  (resolves issue where git diff returned empty due to checkout strategy)
- Replace mshick/add-pr-comment with actions/github-script for all PR
  comments (fixes "Unexpected input 'github-token'" warning)
- Skip pnpm/playwright setup when no web changes detected (faster skip)
- Add diagnostic logging for changed file detection

https://claude.ai/code/session_01XSTqDJXuR4jaLN7SGm3uES
2026-03-01 19:52:59 +00:00
Claude 23e3d43578 fix: add tsx dependency and fix workflow step ordering
- Add tsx as an e2e devDependency (was missing, causing silent failures)
- Replace `npx tsx` with `pnpm exec tsx` throughout
- Move `pnpm install` before `playwright install` (correct ordering)
- Remove `2>/dev/null` that was hiding analyzer errors
- Add debug logging to route analysis step
- Set default working-directory for the job

https://claude.ai/code/session_01XSTqDJXuR4jaLN7SGm3uES
2026-03-01 19:52:59 +00:00
Claude 028c8a2276 fix: use env vars instead of template expansion in visual-review workflow
Moves all ${{ }} expressions out of `run:` blocks and into `env:` to
prevent potential code injection via template expansion (zizmor finding).
Also switches the initial PR comment to use github-script with env vars
instead of add-pr-comment with inline template interpolation.

https://claude.ai/code/session_01XSTqDJXuR4jaLN7SGm3uES
2026-03-01 19:52:59 +00:00
Claude b7f4cc8171 test: add visible navbar changes to test visual review workflow
Adds a red background tint and [VISUAL TEST] label to the navigation bar.
This commit is intended to be reverted after testing the visual-review
workflow — it exercises the dependency analyzer by changing a shared
component used across many pages.

https://claude.ai/code/session_01XSTqDJXuR4jaLN7SGm3uES
2026-03-01 19:52:59 +00:00
Claude 5c11d15008 feat: add visual review workflow for automated PR screenshot comparison
Adds a label-triggered GitHub Actions workflow that automatically generates
before/after screenshots when web UI changes are made in a PR. Uses smart
dependency analysis to only screenshot pages affected by the changed files.

Key components:
- Reverse dependency analyzer: traces changed files through the import graph
  to find which +page.svelte routes are affected
- Screenshot scenarios: Playwright tests using existing mock-network infrastructure
  (no Docker/backend needed) for fast, deterministic screenshots
- Pixel comparison: generates diff images highlighting changed pixels
- GitHub Actions workflow: triggered by 'visual-review' label, posts results
  as a PR comment with change percentages per page

https://claude.ai/code/session_01XSTqDJXuR4jaLN7SGm3uES
2026-03-01 19:52:59 +00:00
118 changed files with 3319 additions and 2794 deletions
+2 -1
View File
@@ -24,7 +24,8 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@65fef71494258f00f911d7a71edb0482c5378899 # v0.0.30
# sha is pinning to a commit instead of a tag since the action does not tag versions
uses: oasdiff/oasdiff-action/breaking@ccb863950ce437a50f8f1a40d2a1112117e06ce4
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
+3 -3
View File
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
with:
languages: ${{ matrix.language }}
# 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).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/autobuild@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
# ️ 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
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
with:
category: '/language:${{matrix.language}}'
+406
View File
@@ -0,0 +1,406 @@
name: Visual Review
on:
pull_request:
types: [labeled, synchronize]
permissions: {}
jobs:
visual-diff:
name: Visual Diff Screenshots
runs-on: ubuntu-latest
if: >-
(github.event.action == 'labeled' && github.event.label.name == 'visual-review') ||
(github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'visual-review'))
permissions:
contents: read
pull-requests: write
defaults:
run:
working-directory: ./e2e
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout PR branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
fetch-depth: 0
- name: Determine changed web files
id: changed-files
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.token.outputs.token }}
script: |
const files = [];
const perPage = 100;
let page = 1;
while (true) {
const { data } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
per_page: perPage,
page,
});
files.push(...data);
if (data.length < perPage) break;
page++;
}
const webPrefixes = ['web/', 'i18n/', 'open-api/typescript-sdk/'];
const webFiles = files
.map(f => f.filename)
.filter(f => webPrefixes.some(p => f.startsWith(p)));
console.log(`Total PR files: ${files.length}`);
console.log(`Web-related files: ${webFiles.length}`);
for (const f of webFiles) {
console.log(` ${f}`);
}
core.setOutput('files', webFiles.join('\n'));
core.setOutput('has_changes', webFiles.length > 0 ? 'true' : 'false');
- name: Setup pnpm
if: steps.changed-files.outputs.has_changes == 'true'
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
if: steps.changed-files.outputs.has_changes == 'true'
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install e2e dependencies
if: steps.changed-files.outputs.has_changes == 'true'
run: pnpm install --frozen-lockfile
- name: Install Playwright
if: steps.changed-files.outputs.has_changes == 'true'
run: pnpm exec playwright install chromium --only-shell
- name: Analyze affected routes
if: steps.changed-files.outputs.has_changes == 'true'
id: routes
env:
CHANGED_FILES: ${{ steps.changed-files.outputs.files }}
run: |
echo "Changed files:"
echo "$CHANGED_FILES"
echo "---"
ROUTES=$(echo "$CHANGED_FILES" | xargs pnpm exec tsx src/screenshots/analyze-deps.ts 2>&1 | tee /dev/stderr | grep "^ /" | sed 's/^ //' || true)
echo "routes<<EOF" >> "$GITHUB_OUTPUT"
echo "$ROUTES" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
if [ -z "$ROUTES" ]; then
echo "has_routes=false" >> "$GITHUB_OUTPUT"
else
echo "has_routes=true" >> "$GITHUB_OUTPUT"
# Build the scenario filter JSON array
SCENARIO_NAMES=$(pnpm exec tsx -e "
import { getScenariosForRoutes } from './src/screenshots/page-map.ts';
const routes = process.argv.slice(1);
const scenarios = getScenariosForRoutes(routes);
console.log(JSON.stringify(scenarios.map(s => s.name)));
" $ROUTES)
echo "scenarios=$SCENARIO_NAMES" >> "$GITHUB_OUTPUT"
echo "Scenarios: $SCENARIO_NAMES"
fi
- name: Post initial comment
if: steps.changed-files.outputs.has_changes == 'true' && steps.routes.outputs.has_routes == 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
AFFECTED_ROUTES: ${{ steps.routes.outputs.routes }}
with:
github-token: ${{ steps.token.outputs.token }}
script: |
const routes = process.env.AFFECTED_ROUTES || '';
const body = `## Visual Review\n\nGenerating screenshots for affected pages...\n\nAffected routes:\n\`\`\`\n${routes}\n\`\`\``;
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.data.find(c => c.body && c.body.includes('## Visual Review'));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
# === Screenshot PR version ===
- name: Build SDK (PR)
if: steps.routes.outputs.has_routes == 'true'
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
- name: Build web (PR)
if: steps.routes.outputs.has_routes == 'true'
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./web
- name: Take screenshots (PR)
if: steps.routes.outputs.has_routes == 'true'
env:
PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS: '1'
SCREENSHOT_OUTPUT_DIR: ${{ github.workspace }}/screenshots/pr
SCREENSHOT_SCENARIOS: ${{ steps.routes.outputs.scenarios }}
SCREENSHOT_BASE_URL: http://127.0.0.1:4173
run: |
# Start the preview server in background
cd ../web && pnpm preview --port 4173 --host 127.0.0.1 &
SERVER_PID=$!
# Wait for server to be ready
for i in $(seq 1 30); do
if curl -s http://127.0.0.1:4173 > /dev/null 2>&1; then
echo "Server ready after ${i}s"
break
fi
sleep 1
done
# Run screenshot tests
pnpm exec playwright test --config playwright.screenshot.config.ts || true
# Stop the preview server and all children (pnpm spawns vite as child)
kill $SERVER_PID 2>/dev/null || true
sleep 1
# Ensure port is fully released — kill any lingering vite process
fuser -k 4173/tcp 2>/dev/null || true
sleep 1
# === Screenshot base version ===
# Disable pnpm's verifyDepsBeforeRun for all base steps since the base
# checkout changes package.json files, making them mismatch the lockfile.
- name: Checkout base web directory
if: steps.routes.outputs.has_routes == 'true'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
# Restore web directory from base branch
git checkout "$BASE_SHA" -- web/ open-api/typescript-sdk/ i18n/ || true
# Clear SvelteKit build cache to avoid stale artifacts from the PR build
rm -rf web/.svelte-kit web/build
working-directory: .
- name: Build SDK (base)
if: steps.routes.outputs.has_routes == 'true'
continue-on-error: true
id: base-sdk
env:
PNPM_VERIFY_DEPS_BEFORE_RUN: 'false'
run: pnpm build
working-directory: ./open-api/typescript-sdk
- name: Build web (base)
if: steps.routes.outputs.has_routes == 'true' && steps.base-sdk.outcome == 'success'
continue-on-error: true
id: base-web
env:
PNPM_VERIFY_DEPS_BEFORE_RUN: 'false'
run: pnpm build
working-directory: ./web
- name: Take screenshots (base)
if: steps.routes.outputs.has_routes == 'true' && steps.base-web.outcome == 'success'
env:
PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS: '1'
SCREENSHOT_OUTPUT_DIR: ${{ github.workspace }}/screenshots/base
SCREENSHOT_SCENARIOS: ${{ steps.routes.outputs.scenarios }}
SCREENSHOT_BASE_URL: http://127.0.0.1:4173
PNPM_VERIFY_DEPS_BEFORE_RUN: 'false'
run: |
# Kill any process still on port 4173 from the PR step
fuser -k 4173/tcp 2>/dev/null || true
sleep 1
# Start the preview server in background
cd ../web && pnpm preview --port 4173 --host 127.0.0.1 &
SERVER_PID=$!
# Wait for server to be ready
for i in $(seq 1 30); do
if curl -s http://127.0.0.1:4173 > /dev/null 2>&1; then
echo "Server ready after ${i}s"
break
fi
sleep 1
done
# Run screenshot tests
pnpm exec playwright test --config playwright.screenshot.config.ts || true
# Stop the preview server
kill $SERVER_PID 2>/dev/null || true
fuser -k 4173/tcp 2>/dev/null || true
- name: Restore PR source
if: steps.routes.outputs.has_routes == 'true'
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
git checkout "$HEAD_SHA" -- web/ open-api/typescript-sdk/ i18n/ || true
working-directory: .
# === Compare and report ===
- name: Compare screenshots
if: steps.routes.outputs.has_routes == 'true'
env:
WORKSPACE_DIR: ${{ github.workspace }}
run: |
# Ensure directories exist even if base screenshots were skipped
mkdir -p "$WORKSPACE_DIR/screenshots/base" "$WORKSPACE_DIR/screenshots/pr" "$WORKSPACE_DIR/screenshots/diff"
pnpm exec tsx src/screenshots/compare.ts \
"$WORKSPACE_DIR/screenshots/base" \
"$WORKSPACE_DIR/screenshots/pr" \
"$WORKSPACE_DIR/screenshots/diff"
- name: Upload screenshot artifacts
if: steps.routes.outputs.has_routes == 'true'
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: visual-review-screenshots
path: screenshots/
retention-days: 14
- name: Upload HTML report
if: steps.routes.outputs.has_routes == 'true'
id: html-report
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
path: screenshots/diff/visual-review.html
archive: false
retention-days: 14
- name: Post comparison results
if: steps.routes.outputs.has_routes == 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
REPORT_URL: ${{ steps.html-report.outputs.artifact-url }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
github-token: ${{ steps.token.outputs.token }}
script: |
const fs = require('fs');
const path = require('path');
const reportPath = path.join(process.env.GITHUB_WORKSPACE, 'screenshots', 'diff', 'report.md');
let body;
try {
body = fs.readFileSync(reportPath, 'utf8');
} catch {
body = '## Visual Review\n\nScreenshot comparison failed. Check the workflow artifacts for details.';
}
// Append links to the HTML report artifact and workflow run
const reportUrl = process.env.REPORT_URL;
const runUrl = process.env.RUN_URL;
body += '\n---\n';
if (reportUrl) {
body += `[View full visual comparison](${reportUrl}) | `;
}
body += `[Download all screenshots](${runUrl}#artifacts)\n`;
// Find and update existing comment or create new one
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.data.find(c =>
c.body && c.body.includes('## Visual Review')
);
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: No web changes
if: steps.changed-files.outputs.has_changes != 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.token.outputs.token }}
script: |
const body = '## Visual Review\n\nNo web-related file changes detected in this PR. Visual review not needed.';
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.data.find(c => c.body && c.body.includes('## Visual Review'));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner, repo: context.repo.repo,
comment_id: existing.id, body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: context.issue.number, body,
});
}
- name: No affected routes
if: steps.changed-files.outputs.has_changes == 'true' && steps.routes.outputs.has_routes != 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.token.outputs.token }}
script: |
const body = '## Visual Review\n\nChanged files don\'t affect any pages with screenshot scenarios configured.\nTo add coverage, define new scenarios in `e2e/src/screenshots/page-map.ts`.';
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.data.find(c => c.body && c.body.includes('## Visual Review'));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner, repo: context.repo.repo,
comment_id: existing.id, body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: context.issue.number, body,
});
}
+1
View File
@@ -24,6 +24,7 @@ open-api/typescript-sdk/build
mobile/android/fastlane/report.xml
mobile/ios/fastlane/report.xml
screenshots-output
vite.config.js.timestamp-*
.pnpm-store
.devcontainer/library
+1 -1
View File
@@ -20,7 +20,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^24.10.14",
"@types/node": "^24.10.13",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
+1 -1
View File
@@ -1,5 +1,5 @@
[tools]
terragrunt = "0.99.4"
terragrunt = "0.98.0"
opentofu = "1.11.4"
[tasks."tg:fmt"]
+1 -1
View File
@@ -155,7 +155,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
healthcheck:
test: redis-cli ping || exit 1
+2 -2
View File
@@ -56,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
healthcheck:
test: redis-cli ping || exit 1
restart: always
@@ -85,7 +85,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:4a61322ac1103a0e3aea2a61ef1718422a48fa046441f299d71e660a3bc71ae9
image: prom/prometheus@sha256:1f0f50f06acaceb0f5670d2c8a658a599affe7b0d8e78b898c1035653849a702
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
+1 -1
View File
@@ -61,7 +61,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
user: '1000:1000'
security_opt:
- no-new-privileges:true
+1 -1
View File
@@ -49,7 +49,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
healthcheck:
test: redis-cli ping || exit 1
restart: always
+3 -4
View File
@@ -67,8 +67,7 @@ graph TD
C --> D["Thumbnail Generation (Large, small, blurred and person)"]
D --> E[Smart Search]
D --> F[Face Detection]
D --> G[OCR]
D --> H[Video Transcoding]
E --> I[Duplicate Detection]
F --> J[Facial Recognition]
D --> G[Video Transcoding]
E --> H[Duplicate Detection]
F --> I[Facial Recognition]
```
+1 -1
View File
@@ -44,7 +44,7 @@ services:
redis:
container_name: immich-e2e-redis
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
healthcheck:
test: redis-cli ping || exit 1
+6 -2
View File
@@ -18,7 +18,10 @@
"format:fix": "prettier --write .",
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
"lint:fix": "pnpm run lint --fix",
"check": "tsc --noEmit"
"check": "tsc --noEmit",
"screenshots": "pnpm exec playwright test --config playwright.screenshot.config.ts",
"screenshots:compare": "pnpm exec tsx src/screenshots/compare.ts",
"screenshots:analyze": "pnpm exec tsx src/screenshots/analyze-deps.ts"
},
"keywords": [],
"author": "",
@@ -32,7 +35,7 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^24.10.14",
"@types/node": "^24.10.13",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2",
@@ -51,6 +54,7 @@
"sharp": "^0.34.5",
"socket.io-client": "^4.7.4",
"supertest": "^7.0.0",
"tsx": "^4.21.0",
"typescript": "^5.3.3",
"typescript-eslint": "^8.28.0",
"utimes": "^5.2.1",
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig, devices } from '@playwright/test';
const baseUrl = process.env.SCREENSHOT_BASE_URL ?? 'http://127.0.0.1:4173';
export default defineConfig({
testDir: './src/screenshots',
testMatch: /run-scenarios\.ts/,
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 0,
reporter: 'list',
use: {
baseURL: baseUrl,
screenshot: 'off',
trace: 'off',
},
workers: 1,
projects: [
{
name: 'screenshots',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1920, height: 1080 },
},
},
],
});
+249
View File
@@ -0,0 +1,249 @@
/**
* Reverse dependency analyzer for the Immich web app.
*
* Given a list of changed files, traces upward through the import graph
* to find which +page.svelte routes are affected, then maps those to URL paths.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
const WEB_SRC = resolve(import.meta.dirname, '../../../web/src');
const LIB_ALIAS = resolve(WEB_SRC, 'lib');
/** Collect all .svelte, .ts, .js files under web/src/ */
function collectFiles(dir: string): string[] {
const results: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
if (entry === 'node_modules' || entry === '.svelte-kit') {
continue;
}
results.push(...collectFiles(full));
} else if (/\.(svelte|ts|js)$/.test(entry)) {
results.push(full);
}
}
return results;
}
/** Extract import specifiers from a file's source text. */
function extractImports(source: string): string[] {
const specifiers: string[] = [];
// Match: import ... from '...' / import '...' / export ... from '...'
const importRegex = /(?:import|export)\s+(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"]/g;
let match;
while ((match = importRegex.exec(source)) !== null) {
specifiers.push(match[1]);
}
// Match dynamic imports: import('...')
const dynamicRegex = /import\(\s*['"]([^'"]+)['"]\s*\)/g;
while ((match = dynamicRegex.exec(source)) !== null) {
specifiers.push(match[1]);
}
return specifiers;
}
/** Resolve an import specifier to an absolute file path (or null if external). */
function resolveImport(specifier: string, fromFile: string, allFiles: Set<string>): string | null {
// Handle $lib alias
let resolved: string;
if (specifier.startsWith('$lib/') || specifier === '$lib') {
resolved = specifier.replace('$lib', LIB_ALIAS);
} else if (specifier.startsWith('./') || specifier.startsWith('../')) {
resolved = resolve(dirname(fromFile), specifier);
} else {
// External package import — not relevant
return null;
}
// Try exact match, then common extensions
const extensions = ['', '.ts', '.js', '.svelte', '/index.ts', '/index.js', '/index.svelte'];
for (const ext of extensions) {
const candidate = resolved + ext;
if (allFiles.has(candidate)) {
return candidate;
}
}
return null;
}
/** Build the forward dependency graph: file → set of files it imports. */
function buildDependencyGraph(files: string[]): Map<string, Set<string>> {
const fileSet = new Set(files);
const graph = new Map<string, Set<string>>();
for (const file of files) {
const deps = new Set<string>();
graph.set(file, deps);
try {
const source = readFileSync(file, 'utf8');
for (const specifier of extractImports(source)) {
const resolved = resolveImport(specifier, file, fileSet);
if (resolved) {
deps.add(resolved);
}
}
} catch {
// Skip files that can't be read
}
}
return graph;
}
/** Invert the dependency graph: file → set of files that import it. */
function buildReverseDependencyGraph(forwardGraph: Map<string, Set<string>>): Map<string, Set<string>> {
const reverse = new Map<string, Set<string>>();
for (const [file, deps] of forwardGraph) {
for (const dep of deps) {
let importers = reverse.get(dep);
if (!importers) {
importers = new Set();
reverse.set(dep, importers);
}
importers.add(file);
}
}
return reverse;
}
/** BFS from changed files upward through reverse deps to find +page.svelte files. */
function findAffectedPages(changedFiles: string[], reverseGraph: Map<string, Set<string>>): Set<string> {
const visited = new Set<string>();
const pages = new Set<string>();
const queue = [...changedFiles];
while (queue.length > 0) {
const file = queue.shift()!;
if (visited.has(file)) {
continue;
}
visited.add(file);
if (file.endsWith('+page.svelte') || file.endsWith('+layout.svelte')) {
pages.add(file);
// If it's a layout, keep tracing upward because the layout itself
// isn't a page — but the pages under it are affected.
// If it's a +page.svelte, we still want to continue in case
// this page is imported by others.
}
const importers = reverseGraph.get(file);
if (importers) {
for (const importer of importers) {
if (!visited.has(importer)) {
queue.push(importer);
}
}
}
}
// For +layout.svelte hits, also find all +page.svelte under the same directory tree
const layoutDirs: string[] = [];
for (const page of pages) {
if (page.endsWith('+layout.svelte')) {
layoutDirs.push(dirname(page));
pages.delete(page);
}
}
if (layoutDirs.length > 0) {
for (const file of reverseGraph.keys()) {
if (file.endsWith('+page.svelte')) {
for (const layoutDir of layoutDirs) {
if (file.startsWith(layoutDir)) {
pages.add(file);
}
}
}
}
// Also check the forward graph keys for page files under layout dirs
for (const layoutDir of layoutDirs) {
const allFiles = collectFiles(layoutDir);
for (const f of allFiles) {
if (f.endsWith('+page.svelte')) {
pages.add(f);
}
}
}
}
return pages;
}
/** Convert a +page.svelte file path to its URL route. */
export function pageFileToRoute(pageFile: string): string {
const routesDir = resolve(WEB_SRC, 'routes');
let rel = relative(routesDir, dirname(pageFile));
// Remove SvelteKit group markers: (user), (list), etc.
rel = rel.replaceAll(/\([^)]+\)\/?/g, '');
// Remove parameter segments: [albumId=id], [[photos=photos]], [[assetId=id]]
rel = rel.replaceAll(/\[\[?[^\]]+\]\]?\/?/g, '');
// Clean up trailing slashes and normalize
rel = rel.replaceAll(/\/+/g, '/').replace(/\/$/, '');
return '/' + rel;
}
export interface AnalysisResult {
affectedPages: string[];
affectedRoutes: string[];
}
/** Main entry: analyze which routes are affected by the given changed files. */
export function analyzeAffectedRoutes(changedFiles: string[]): AnalysisResult {
// Resolve changed files to absolute paths relative to web/src
const webRoot = resolve(WEB_SRC, '..');
const resolvedChanged = changedFiles
.filter((f) => f.startsWith('web/'))
.map((f) => resolve(webRoot, '..', f))
.filter((f) => statSync(f, { throwIfNoEntry: false })?.isFile());
if (resolvedChanged.length === 0) {
return { affectedPages: [], affectedRoutes: [] };
}
const allFiles = collectFiles(WEB_SRC);
const forwardGraph = buildDependencyGraph(allFiles);
const reverseGraph = buildReverseDependencyGraph(forwardGraph);
const pages = findAffectedPages(resolvedChanged, reverseGraph);
const affectedPages = [...pages].toSorted();
const affectedRoutes = [...new Set(affectedPages.map((f) => pageFileToRoute(f)))].toSorted();
return { affectedPages, affectedRoutes };
}
// CLI usage: node --import tsx analyze-deps.ts file1 file2 ...
if (process.argv[1]?.endsWith('analyze-deps.ts') || process.argv[1]?.endsWith('analyze-deps.js')) {
const files = process.argv.slice(2);
if (files.length === 0) {
console.log('Usage: analyze-deps.ts <changed-file1> <changed-file2> ...');
console.log('Files should be relative to the repo root (e.g. web/src/lib/components/Button.svelte)');
throw new Error('No files provided');
}
const result = analyzeAffectedRoutes(files);
console.log('Affected pages:');
for (const page of result.affectedPages) {
console.log(` ${page}`);
}
console.log('\nAffected routes:');
for (const route of result.affectedRoutes) {
console.log(` ${route}`);
}
}
+335
View File
@@ -0,0 +1,335 @@
/**
* Pixel-level comparison of base vs PR screenshots.
*
* Uses pixelmatch to generate diff images and calculate change percentages.
*
* Usage:
* npx tsx e2e/src/screenshots/compare.ts <base-dir> <pr-dir> <output-dir>
*/
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { basename, join, resolve } from 'node:path';
import { PNG } from 'pngjs';
// pixelmatch is a lightweight dependency — use a simple inline implementation
// based on the approach from the pixelmatch library to avoid adding a new dependency.
// The e2e package already has pngjs.
function pixelMatch(img1Data: Uint8Array, img2Data: Uint8Array, diffData: Uint8Array): number {
let diffCount = 0;
for (let i = 0; i < img1Data.length; i += 4) {
const r1 = img1Data[i];
const g1 = img1Data[i + 1];
const b1 = img1Data[i + 2];
const r2 = img2Data[i];
const g2 = img2Data[i + 1];
const b2 = img2Data[i + 2];
const dr = Math.abs(r1 - r2);
const dg = Math.abs(g1 - g2);
const db = Math.abs(b1 - b2);
// Threshold: if any channel differs by more than 25, mark as different
const isDiff = dr > 25 || dg > 25 || db > 25;
if (isDiff) {
// Red highlight for diff pixels
diffData[i] = 255;
diffData[i + 1] = 0;
diffData[i + 2] = 0;
diffData[i + 3] = 255;
diffCount++;
} else {
// Dimmed original for unchanged pixels
const gray = Math.round(0.299 * r1 + 0.587 * g1 + 0.114 * b1);
diffData[i] = gray;
diffData[i + 1] = gray;
diffData[i + 2] = gray;
diffData[i + 3] = 128;
}
}
return diffCount;
}
export interface ComparisonResult {
name: string;
baseExists: boolean;
prExists: boolean;
diffPixels: number;
totalPixels: number;
changePercent: number;
diffImagePath: string | null;
baseImagePath: string | null;
prImagePath: string | null;
}
export function compareScreenshots(baseDir: string, prDir: string, outputDir: string): ComparisonResult[] {
mkdirSync(outputDir, { recursive: true });
// Collect all screenshot names from both directories
const baseFiles = existsSync(baseDir)
? new Set(readdirSync(baseDir).filter((f) => f.endsWith('.png')))
: new Set<string>();
const prFiles = existsSync(prDir) ? new Set(readdirSync(prDir).filter((f) => f.endsWith('.png'))) : new Set<string>();
const allNames = new Set([...baseFiles, ...prFiles]);
const results: ComparisonResult[] = [];
for (const fileName of [...allNames].toSorted()) {
const name = basename(fileName, '.png');
const basePath = join(baseDir, fileName);
const prPath = join(prDir, fileName);
const baseExists = baseFiles.has(fileName);
const prExists = prFiles.has(fileName);
if (!baseExists || !prExists) {
// New or removed page
results.push({
name,
baseExists,
prExists,
diffPixels: -1,
totalPixels: -1,
changePercent: 100,
diffImagePath: null,
baseImagePath: baseExists ? basePath : null,
prImagePath: prExists ? prPath : null,
});
continue;
}
// Load both PNGs
const basePng = PNG.sync.read(readFileSync(basePath));
const prPng = PNG.sync.read(readFileSync(prPath));
// Handle size mismatches by comparing the overlapping region
const width = Math.max(basePng.width, prPng.width);
const height = Math.max(basePng.height, prPng.height);
// Resize images to the same dimensions (pad with transparent)
const normalizedBase = normalizeImage(basePng, width, height);
const normalizedPr = normalizeImage(prPng, width, height);
const diffPng = new PNG({ width, height });
const totalPixels = width * height;
const diffPixels = pixelMatch(normalizedBase, normalizedPr, diffPng.data as unknown as Uint8Array);
const diffImagePath = join(outputDir, `${name}-diff.png`);
writeFileSync(diffImagePath, PNG.sync.write(diffPng));
results.push({
name,
baseExists,
prExists,
diffPixels,
totalPixels,
changePercent: totalPixels > 0 ? (diffPixels / totalPixels) * 100 : 0,
diffImagePath,
baseImagePath: basePath,
prImagePath: prPath,
});
}
return results;
}
function normalizeImage(png: PNG, targetWidth: number, targetHeight: number): Uint8Array {
if (png.width === targetWidth && png.height === targetHeight) {
return png.data as unknown as Uint8Array;
}
const data = new Uint8Array(targetWidth * targetHeight * 4);
for (let y = 0; y < targetHeight; y++) {
for (let x = 0; x < targetWidth; x++) {
const targetIdx = (y * targetWidth + x) * 4;
if (x < png.width && y < png.height) {
const sourceIdx = (y * png.width + x) * 4;
data[targetIdx] = png.data[sourceIdx];
data[targetIdx + 1] = png.data[sourceIdx + 1];
data[targetIdx + 2] = png.data[sourceIdx + 2];
data[targetIdx + 3] = png.data[sourceIdx + 3];
} else {
// Transparent padding
data[targetIdx + 3] = 0;
}
}
}
return data;
}
/** Generate a text-only markdown summary for the PR comment. */
export function generateMarkdownReport(results: ComparisonResult[]): string {
const changed = results.filter((r) => r.changePercent > 0.1);
const unchanged = results.filter((r) => r.changePercent <= 0.1);
if (changed.length === 0) {
return '## Visual Review\n\nNo visual changes detected in the affected pages.';
}
let md = '## Visual Review\n\n';
md += `Found **${changed.length}** page(s) with visual changes`;
if (unchanged.length > 0) {
md += ` (${unchanged.length} unchanged)`;
}
md += '.\n\n';
md += '| Page | Status | Change |\n';
md += '|------|--------|--------|\n';
for (const result of changed) {
if (result.baseExists && result.prExists) {
md += `| ${result.name} | Changed | ${result.changePercent.toFixed(1)}% |\n`;
} else if (result.prExists) {
md += `| ${result.name} | New | - |\n`;
} else {
md += `| ${result.name} | Removed | - |\n`;
}
}
md += '\n';
if (unchanged.length > 0) {
md += '<details>\n<summary>Unchanged pages</summary>\n\n';
for (const result of unchanged) {
md += `- ${result.name}\n`;
}
md += '\n</details>\n';
}
return md;
}
function imgTag(filePath: string | null, alt: string): string {
if (!filePath || !existsSync(filePath)) {
return `<div class="no-image">${alt} not available</div>`;
}
const data = readFileSync(filePath);
return `<img src="data:image/png;base64,${data.toString('base64')}" alt="${alt}" loading="lazy" />`;
}
/** Generate an HTML report with embedded base64 images for the artifact. */
export function generateHtmlReport(results: ComparisonResult[]): string {
const changed = results.filter((r) => r.changePercent > 0.1);
const unchanged = results.filter((r) => r.changePercent <= 0.1);
let html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Visual Review</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
background: #0d1117; color: #e6edf3; padding: 32px; line-height: 1.5; }
.container { max-width: 1800px; margin: 0 auto; }
h1 { font-size: 24px; border-bottom: 1px solid #30363d; padding-bottom: 12px; margin-bottom: 24px; }
.summary { color: #8b949e; margin-bottom: 32px; font-size: 16px; }
.scenario { margin-bottom: 40px; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
.scenario-header { background: #161b22; padding: 12px 16px; display: flex; align-items: center; gap: 12px; }
.scenario-header h2 { font-size: 16px; font-weight: 600; }
.badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 500; }
.badge-changed { background: #da363380; color: #f85149; }
.badge-new { background: #1f6feb80; color: #58a6ff; }
.badge-removed { background: #6e767e80; color: #8b949e; }
.grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1px; background: #30363d; }
.grid-cell { background: #0d1117; }
.grid-label { text-align: center; padding: 8px; font-size: 13px; color: #8b949e; font-weight: 600;
background: #161b22; text-transform: uppercase; letter-spacing: 0.5px; }
.grid-cell img { width: 100%; display: block; }
.no-image { padding: 40px; text-align: center; color: #484f58; font-style: italic; }
.unchanged-section { margin-top: 32px; color: #8b949e; }
.unchanged-section summary { cursor: pointer; font-size: 14px; }
.unchanged-section ul { margin-top: 8px; padding-left: 24px; }
.unchanged-section li { font-size: 14px; margin: 4px 0; }
</style>
</head>
<body>
<div class="container">
<h1>Visual Review</h1>
`;
if (changed.length === 0) {
html += '<p class="summary">No visual changes detected in the affected pages.</p>';
} else {
html += `<p class="summary">Found <strong>${changed.length}</strong> page(s) with visual changes`;
if (unchanged.length > 0) {
html += ` (${unchanged.length} unchanged)`;
}
html += '.</p>\n';
for (const result of changed) {
html += '<div class="scenario">\n<div class="scenario-header">\n';
html += `<h2>${result.name}</h2>\n`;
if (!result.baseExists) {
html += '<span class="badge badge-new">New</span>\n';
html += '</div>\n';
html += `<div style="padding: 16px;">${imgTag(result.prImagePath, 'PR')}</div>\n`;
html += '</div>\n';
continue;
}
if (!result.prExists) {
html += '<span class="badge badge-removed">Removed</span>\n';
html += '</div>\n</div>\n';
continue;
}
html += `<span class="badge badge-changed">${result.changePercent.toFixed(1)}% changed</span>\n`;
html += '</div>\n';
html += '<div class="grid">\n';
html += `<div class="grid-cell"><div class="grid-label">Base</div>${imgTag(result.baseImagePath, 'Base')}</div>\n`;
html += `<div class="grid-cell"><div class="grid-label">PR</div>${imgTag(result.prImagePath, 'PR')}</div>\n`;
html += `<div class="grid-cell"><div class="grid-label">Diff</div>${imgTag(result.diffImagePath, 'Diff')}</div>\n`;
html += '</div>\n</div>\n';
}
}
if (unchanged.length > 0) {
html += '<div class="unchanged-section">\n<details>\n<summary>Unchanged pages</summary>\n<ul>\n';
for (const result of unchanged) {
html += `<li>${result.name}</li>\n`;
}
html += '</ul>\n</details>\n</div>\n';
}
html += '</div>\n</body>\n</html>';
return html;
}
// CLI usage
if (process.argv[1]?.endsWith('compare.ts') || process.argv[1]?.endsWith('compare.js')) {
const [baseDir, prDir, outputDir] = process.argv.slice(2);
if (!baseDir || !prDir || !outputDir) {
throw new Error('Usage: compare.ts <base-dir> <pr-dir> <output-dir>');
}
const resolvedOutputDir = resolve(outputDir);
const results = compareScreenshots(resolve(baseDir), resolve(prDir), resolvedOutputDir);
console.log('\nComparison Results:');
console.log('==================');
for (const r of results) {
const status = r.changePercent > 0.1 ? 'CHANGED' : 'unchanged';
console.log(` ${r.name}: ${status} (${r.changePercent.toFixed(1)}%)`);
}
const report = generateMarkdownReport(results);
const reportPath = join(resolvedOutputDir, 'report.md');
writeFileSync(reportPath, report);
console.log(`\nMarkdown report written to: ${reportPath}`);
const htmlReport = generateHtmlReport(results);
const htmlPath = join(resolvedOutputDir, 'visual-review.html');
writeFileSync(htmlPath, htmlReport);
console.log(`HTML report written to: ${htmlPath}`);
const jsonPath = join(resolvedOutputDir, 'results.json');
writeFileSync(jsonPath, JSON.stringify(results, null, 2));
console.log(`Results JSON written to: ${jsonPath}`);
}
+187
View File
@@ -0,0 +1,187 @@
/**
* Maps URL routes to screenshot scenario keys.
*
* Routes discovered by the dependency analyzer are matched against this map
* to determine which screenshot scenarios to run. Routes not in this map
* are skipped (they don't have a scenario defined yet).
*/
export interface ScenarioDefinition {
/** The URL path to navigate to */
url: string;
/** Human-readable name for the screenshot file */
name: string;
/** Which mock networks this scenario needs */
mocks: ('base' | 'timeline' | 'memory')[];
/** Optional: selector to wait for before screenshotting */
waitForSelector?: string;
/** Optional: time to wait after page load (ms) for animations to settle */
settleTime?: number;
}
/**
* Map from route paths (as output by analyze-deps) to scenario definitions.
* A single route might map to multiple scenarios (e.g., different states).
*/
export const PAGE_SCENARIOS: Record<string, ScenarioDefinition[]> = {
'/photos': [
{
url: '/photos',
name: 'photos-timeline',
mocks: ['base', 'timeline'],
waitForSelector: '[data-thumbnail-focus-container]',
settleTime: 500,
},
],
'/albums': [
{
url: '/albums',
name: 'albums-list',
mocks: ['base'],
settleTime: 300,
},
],
'/explore': [
{
url: '/explore',
name: 'explore',
mocks: ['base'],
settleTime: 300,
},
],
'/favorites': [
{
url: '/favorites',
name: 'favorites',
mocks: ['base', 'timeline'],
waitForSelector: '#asset-grid',
settleTime: 300,
},
],
'/archive': [
{
url: '/archive',
name: 'archive',
mocks: ['base', 'timeline'],
waitForSelector: '#asset-grid',
settleTime: 300,
},
],
'/trash': [
{
url: '/trash',
name: 'trash',
mocks: ['base', 'timeline'],
waitForSelector: '#asset-grid',
settleTime: 300,
},
],
'/people': [
{
url: '/people',
name: 'people',
mocks: ['base'],
settleTime: 300,
},
],
'/sharing': [
{
url: '/sharing',
name: 'sharing',
mocks: ['base'],
settleTime: 300,
},
],
'/search': [
{
url: '/search',
name: 'search',
mocks: ['base'],
settleTime: 300,
},
],
'/memory': [
{
url: '/memory',
name: 'memory',
mocks: ['base', 'memory'],
settleTime: 500,
},
],
'/user-settings': [
{
url: '/user-settings',
name: 'user-settings',
mocks: ['base'],
settleTime: 300,
},
],
'/map': [
{
url: '/map',
name: 'map',
mocks: ['base'],
settleTime: 500,
},
],
'/admin': [
{
url: '/admin',
name: 'admin-dashboard',
mocks: ['base'],
settleTime: 300,
},
],
'/admin/system-settings': [
{
url: '/admin/system-settings',
name: 'admin-system-settings',
mocks: ['base'],
settleTime: 300,
},
],
'/admin/users': [
{
url: '/admin/users',
name: 'admin-users',
mocks: ['base'],
settleTime: 300,
},
],
'/auth/login': [
{
url: '/auth/login',
name: 'login',
mocks: [],
settleTime: 300,
},
],
'/': [
{
url: '/',
name: 'landing',
mocks: [],
settleTime: 300,
},
],
};
/** Given a list of routes from the analyzer, return the matching scenarios. */
export function getScenariosForRoutes(routes: string[]): ScenarioDefinition[] {
const scenarios: ScenarioDefinition[] = [];
const seen = new Set<string>();
for (const route of routes) {
const defs = PAGE_SCENARIOS[route];
if (defs) {
for (const def of defs) {
if (!seen.has(def.name)) {
seen.add(def.name);
scenarios.push(def);
}
}
}
}
return scenarios;
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Playwright script to capture screenshots for visual diff scenarios.
*
* Usage:
* npx playwright test --config e2e/playwright.screenshot.config.ts
*
* Environment variables:
* SCREENSHOT_SCENARIOS - JSON array of scenario names to run (from page-map.ts)
* If not set, runs all scenarios.
* SCREENSHOT_OUTPUT_DIR - Directory to save screenshots to. Defaults to e2e/screenshots-output.
*/
import { faker } from '@faker-js/faker';
import type { MemoryResponseDto } from '@immich/sdk';
import { test } from '@playwright/test';
import { mkdirSync } from 'node:fs';
import { resolve } from 'node:path';
import { generateMemoriesFromTimeline } from 'src/ui/generators/memory';
import {
createDefaultTimelineConfig,
generateTimelineData,
type TimelineAssetConfig,
type TimelineData,
} from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
import { setupMemoryMockApiRoutes } from 'src/ui/mock-network/memory-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
import { PAGE_SCENARIOS, type ScenarioDefinition } from './page-map';
const OUTPUT_DIR = process.env.SCREENSHOT_OUTPUT_DIR || resolve(import.meta.dirname, '../../../screenshots-output');
const SCENARIO_FILTER: string[] | null = process.env.SCREENSHOT_SCENARIOS
? JSON.parse(process.env.SCREENSHOT_SCENARIOS)
: null;
// Collect scenarios to run
const allScenarios: ScenarioDefinition[] = [];
for (const defs of Object.values(PAGE_SCENARIOS)) {
for (const def of defs) {
if (!SCENARIO_FILTER || SCENARIO_FILTER.includes(def.name)) {
allScenarios.push(def);
}
}
}
// Use a fixed seed so screenshots are deterministic across runs
faker.seed(42);
let adminUserId: string;
let timelineData: TimelineData;
let timelineAssets: TimelineAssetConfig[];
let memories: MemoryResponseDto[];
test.beforeAll(async () => {
adminUserId = faker.string.uuid();
timelineData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
timelineAssets = [];
for (const timeBucket of timelineData.buckets.values()) {
timelineAssets.push(...timeBucket);
}
memories = generateMemoriesFromTimeline(
timelineAssets,
adminUserId,
[
{ year: 2024, assetCount: 3 },
{ year: 2023, assetCount: 2 },
],
42,
);
mkdirSync(OUTPUT_DIR, { recursive: true });
});
for (const scenario of allScenarios) {
test(`Screenshot: ${scenario.name}`, async ({ context, page }) => {
// Set up mocks based on scenario requirements
if (scenario.mocks.includes('base')) {
await setupBaseMockApiRoutes(context, adminUserId);
}
if (scenario.mocks.includes('timeline')) {
const testContext = new TimelineTestContext();
testContext.adminId = adminUserId;
await setupTimelineMockApiRoutes(
context,
timelineData,
{
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
},
testContext,
);
}
if (scenario.mocks.includes('memory')) {
await setupMemoryMockApiRoutes(context, memories, {
memoryDeletions: [],
assetRemovals: new Map(),
});
}
// Navigate to the page. Use networkidle so SvelteKit hydrates and API
// calls complete, but fall back to domcontentloaded if it times out
// (e.g. a persistent connection the catch-all mock didn't cover).
try {
await page.goto(scenario.url, { waitUntil: 'networkidle', timeout: 15_000 });
} catch {
console.warn(`networkidle timed out for ${scenario.name}, falling back to current state`);
// Page has already navigated, just continue with what we have
}
// Wait for specific selector if specified
if (scenario.waitForSelector) {
try {
await page.waitForSelector(scenario.waitForSelector, { timeout: 15_000 });
} catch {
console.warn(`Selector ${scenario.waitForSelector} not found for ${scenario.name}, continuing...`);
}
}
// Wait for loading spinners to disappear
await page
.waitForFunction(() => document.querySelectorAll('[data-testid="loading-spinner"]').length === 0, {
timeout: 10_000,
})
.catch(() => {});
// Wait for animations/transitions to settle
await page.waitForTimeout(scenario.settleTime ?? 500);
// Take the screenshot
await page.screenshot({
path: resolve(OUTPUT_DIR, `${scenario.name}.png`),
fullPage: false,
});
});
}
@@ -0,0 +1,66 @@
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
import { expect, Page, test } from '@playwright/test';
import { utils } from 'src/utils';
async function ensureDetailPanelVisible(page: Page) {
await page.waitForSelector('#immich-asset-viewer');
const isVisible = await page.locator('#detail-panel').isVisible();
if (!isVisible) {
await page.keyboard.press('i');
await page.waitForSelector('#detail-panel');
}
}
test.describe('Asset Viewer stack', () => {
let admin: LoginResponseDto;
let assetOne: AssetMediaResponseDto;
let assetTwo: AssetMediaResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
await utils.updateMyPreferences(admin.accessToken, { tags: { enabled: true } });
assetOne = await utils.createAsset(admin.accessToken);
assetTwo = await utils.createAsset(admin.accessToken);
await utils.createStack(admin.accessToken, [assetOne.id, assetTwo.id]);
const tags = await utils.upsertTags(admin.accessToken, ['test/1', 'test/2']);
const tagOne = tags.find((tag) => tag.value === 'test/1')!;
const tagTwo = tags.find((tag) => tag.value === 'test/2')!;
await utils.tagAssets(admin.accessToken, tagOne.id, [assetOne.id]);
await utils.tagAssets(admin.accessToken, tagTwo.id, [assetTwo.id]);
});
test('stack slideshow is visible', async ({ page, context }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/photos/${assetOne.id}`);
const stackAssets = page.locator('#stack-slideshow [data-asset]');
await expect(stackAssets.first()).toBeVisible();
await expect(stackAssets.nth(1)).toBeVisible();
});
test('tags of primary asset are visible', async ({ page, context }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/photos/${assetOne.id}`);
await ensureDetailPanelVisible(page);
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
await expect(tags.first()).toHaveText('test/1');
});
test('tags of second asset are visible', async ({ page, context }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/photos/${assetOne.id}`);
await ensureDetailPanelVisible(page);
const stackAssets = page.locator('#stack-slideshow [data-asset]');
await stackAssets.nth(1).click();
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
await expect(tags.first()).toHaveText('test/2');
});
});
+21
View File
@@ -10,6 +10,27 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
path: '/',
},
]);
// Block socket.io connections — these are persistent WebSocket connections
// that prevent networkidle from resolving since there's no real server.
await context.route('**/api/socket.io**', async (route) => {
return route.abort('connectionrefused');
});
// Catch-all for any /api/ endpoint not explicitly mocked below.
// Registered FIRST so specific routes (registered after) take priority
// (Playwright checks routes in reverse registration order).
// Without this, unmocked API calls hit the static preview server which
// either hangs or returns HTML, preventing networkidle and causing timeouts.
await context.route('**/api/**', async (route) => {
const method = route.request().method();
return route.fulfill({
status: 200,
contentType: 'application/json',
json: method === 'GET' ? [] : {},
});
});
await context.route('**/api/users/me', async (route) => {
return route.fulfill({
status: 200,
@@ -1,167 +0,0 @@
import { faker } from '@faker-js/faker';
import { AssetTypeEnum, AssetVisibility, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
import { randomPreview, randomThumbnail } from 'src/ui/generators/timeline';
export type MockStack = {
id: string;
primaryAssetId: string;
assets: AssetResponseDto[];
brokenAssetIds: Set<string>;
assetMap: Map<string, AssetResponseDto>;
};
export const createMockStackAsset = (ownerId: string): AssetResponseDto => {
const assetId = faker.string.uuid();
const now = new Date().toISOString();
return {
id: assetId,
deviceAssetId: `device-${assetId}`,
ownerId,
owner: {
id: ownerId,
email: 'admin@immich.cloud',
name: 'Admin',
profileImagePath: '',
profileChangedAt: now,
avatarColor: 'blue' as never,
},
libraryId: `library-${ownerId}`,
deviceId: `device-${ownerId}`,
type: AssetTypeEnum.Image,
originalPath: `/original/${assetId}.jpg`,
originalFileName: `${assetId}.jpg`,
originalMimeType: 'image/jpeg',
thumbhash: null,
fileCreatedAt: now,
fileModifiedAt: now,
localDateTime: now,
updatedAt: now,
createdAt: now,
isFavorite: false,
isArchived: false,
isTrashed: false,
visibility: AssetVisibility.Timeline,
duration: '0:00:00.00000',
exifInfo: {
make: null,
model: null,
exifImageWidth: 3000,
exifImageHeight: 4000,
fileSizeInByte: null,
orientation: null,
dateTimeOriginal: now,
modifyDate: null,
timeZone: null,
lensModel: null,
fNumber: null,
focalLength: null,
iso: null,
exposureTime: null,
latitude: null,
longitude: null,
city: null,
country: null,
state: null,
description: null,
},
livePhotoVideoId: null,
tags: [],
people: [],
unassignedFaces: [],
stack: null,
isOffline: false,
hasMetadata: true,
duplicateId: null,
resized: true,
checksum: faker.string.alphanumeric({ length: 28 }),
width: 3000,
height: 4000,
isEdited: false,
};
};
export const createMockStack = (
primaryAssetDto: AssetResponseDto,
additionalAssets: AssetResponseDto[],
brokenAssetIds?: Set<string>,
): MockStack => {
const stackId = faker.string.uuid();
const allAssets = [primaryAssetDto, ...additionalAssets];
const resolvedBrokenIds = brokenAssetIds ?? new Set(additionalAssets.map((a) => a.id));
const assetMap = new Map(allAssets.map((a) => [a.id, a]));
primaryAssetDto.stack = {
id: stackId,
assetCount: allAssets.length,
primaryAssetId: primaryAssetDto.id,
};
return {
id: stackId,
primaryAssetId: primaryAssetDto.id,
assets: allAssets,
brokenAssetIds: resolvedBrokenIds,
assetMap,
};
};
export const setupBrokenAssetMockApiRoutes = async (context: BrowserContext, mockStack: MockStack) => {
await context.route('**/api/stacks/*', async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
const stackResponse: StackResponseDto = {
id: mockStack.id,
primaryAssetId: mockStack.primaryAssetId,
assets: mockStack.assets,
};
return route.fulfill({
status: 200,
contentType: 'application/json',
json: stackResponse,
});
});
await context.route('**/api/assets/*', async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
const url = new URL(request.url());
const segments = url.pathname.split('/');
const assetId = segments.at(-1);
if (assetId && mockStack.assetMap.has(assetId)) {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: mockStack.assetMap.get(assetId),
});
}
return route.fallback();
});
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
if (!route.request().serviceWorker()) {
return route.continue();
}
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
const match = request.url().match(pattern);
if (!match?.groups || !mockStack.assetMap.has(match.groups.assetId)) {
return route.fallback();
}
if (mockStack.brokenAssetIds.has(match.groups.assetId)) {
return route.fulfill({ status: 404 });
}
const asset = mockStack.assetMap.get(match.groups.assetId)!;
const ratio = (asset.exifInfo?.exifImageWidth ?? 3000) / (asset.exifInfo?.exifImageHeight ?? 4000);
const body =
match.groups.size === 'preview'
? await randomPreview(match.groups.assetId, ratio)
: await randomThumbnail(match.groups.assetId, ratio);
return route.fulfill({
status: 200,
headers: { 'content-type': 'image/jpeg' },
body,
});
});
};
@@ -1,127 +0,0 @@
import { BrowserContext } from '@playwright/test';
import { randomThumbnail } from 'src/ui/generators/timeline';
// Minimal valid H.264 MP4 (8x8px, 1 frame) that browsers can decode to get videoWidth/videoHeight
const MINIMAL_MP4_BASE64 =
'AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAr9tZGF0AAACoAYF//+c' +
'3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDEyNSAtIEguMjY0L01QRUctNCBBVkMgY29kZWMg' +
'LSBDb3B5bGVmdCAyMDAzLTIwMTIgLSBodHRwOi8vd3d3LnZpZGVvbGFuLm9yZy94MjY0Lmh0bWwg' +
'LSBvcHRpb25zOiBjYWJhYz0xIHJlZj0zIGRlYmxvY2s9MTowOjAgYW5hbHlzZT0weDM6MHgxMTMg' +
'bWU9aGV4IHN1Ym1lPTcgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5n' +
'ZT0xNiBjaHJvbWFfbWU9MSB0cmVsbGlzPTEgOHg4ZGN0PTEgY3FtPTAgZGVhZHpvbmU9MjEsMTEg' +
'ZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhyZWFkcz02IGxvb2thaGVhZF90aHJl' +
'YWRzPTEgc2xpY2VkX3RocmVhZHM9MCBucj0wIGRlY2ltYXRlPTEgaW50ZXJsYWNlZD0wIGJsdXJh' +
'eV9jb21wYXQ9MCBjb25zdHJhaW5lZF9pbnRyYT0wIGJmcmFtZXM9MyBiX3B5cmFtaWQ9MiBiX2Fk' +
'YXB0PTEgYl9iaWFzPTAgZGlyZWN0PTEgd2VpZ2h0Yj0xIG9wZW5fZ29wPTAgd2VpZ2h0cD0yIGtl' +
'eWludD0yNTAga2V5aW50X21pbj0yNCBzY2VuZWN1dD00MCBpbnRyYV9yZWZyZXNoPTAgcmNfbG9v' +
'a2FoZWFkPTQwIHJjPWNyZiBtYnRyZWU9MSBjcmY9MjMuMCBxY29tcD0wLjYwIHFwbWluPTAgcXBt' +
'YXg9NjkgcXBzdGVwPTQgaXBfcmF0aW89MS40MCBhcT0xOjEuMDAAgAAAAA9liIQAV/0TAAYdeBTX' +
'zg8AAALvbW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAACoAAQAAAQAAAAAAAAAAAAAAAAEAAAAA' +
'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAA' +
'Ahl0cmFrAAAAXHRraGQAAAAPAAAAAAAAAAAAAAABAAAAAAAAACoAAAAAAAAAAAAAAAAAAAAAAAEAAAAA' +
'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAgAAAAIAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAA' +
'AAEAAAAqAAAAAAABAAAAAAGRbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAwAAAAAgBVxAAAAAAA' +
'LWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABPG1pbmYAAAAUdm1oZAAA' +
'AAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAPxzdGJsAAAAmHN0' +
'c2QAAAAAAAAAAQAAAIhhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAgACABIAAAASAAAAAAAAAAB' +
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAMmF2Y0MBZAAK/+EAGWdkAAqs' +
'2V+WXAWyAAADAAIAAAMAYB4kSywBAAZo6+PLIsAAAAAYc3R0cwAAAAAAAAABAAAAAQAAAgAAAAAcc3Rz' +
'YwAAAAAAAAABAAAAAQAAAAEAAAABAAAAFHN0c3oAAAAAAAACtwAAAAEAAAAUc3RjbwAAAAAAAAABAAAA' +
'MAAAAGJ1ZHRhAAAAWm1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALWls' +
'c3QAAAAlqXRvbwAAAB1kYXRhAAAAAQAAAABMYXZmNTQuNjMuMTA0';
export const MINIMAL_MP4_BUFFER = Buffer.from(MINIMAL_MP4_BASE64, 'base64');
export type MockPerson = {
id: string;
name: string;
birthDate: string | null;
isHidden: boolean;
thumbnailPath: string;
updatedAt: string;
};
export const createMockPeople = (count: number): MockPerson[] => {
const names = [
'Alice Johnson',
'Bob Smith',
'Charlie Brown',
'Diana Prince',
'Eve Adams',
'Frank Castle',
'Grace Lee',
'Hank Pym',
'Iris West',
'Jack Ryan',
];
return Array.from({ length: count }, (_, index) => ({
id: `person-${index}`,
name: names[index % names.length],
birthDate: null,
isHidden: false,
thumbnailPath: `/upload/thumbs/person-${index}.jpeg`,
updatedAt: '2025-01-01T00:00:00.000Z',
}));
};
export type FaceCreateCapture = {
requests: Array<{
assetId: string;
personId: string;
x: number;
y: number;
width: number;
height: number;
imageWidth: number;
imageHeight: number;
}>;
};
export const setupFaceEditorMockApiRoutes = async (
context: BrowserContext,
mockPeople: MockPerson[],
faceCreateCapture: FaceCreateCapture,
) => {
await context.route('**/api/people?*', async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
hasNextPage: false,
hidden: 0,
people: mockPeople,
total: mockPeople.length,
},
});
});
await context.route('**/api/faces', async (route, request) => {
if (request.method() !== 'POST') {
return route.fallback();
}
const body = request.postDataJSON();
faceCreateCapture.requests.push(body);
return route.fulfill({
status: 201,
contentType: 'text/plain',
body: 'OK',
});
});
await context.route('**/api/people/*/thumbnail', async (route) => {
if (!route.request().serviceWorker()) {
return route.continue();
}
return route.fulfill({
status: 200,
headers: { 'content-type': 'image/jpeg' },
body: await randomThumbnail('person-thumb', 1),
});
});
};
@@ -12,7 +12,6 @@ import {
TimelineData,
} from 'src/ui/generators/timeline';
import { sleep } from 'src/ui/specs/timeline/utils';
import { MINIMAL_MP4_BUFFER } from './face-editor-network';
export class TimelineTestContext {
slowBucket = false;
@@ -136,14 +135,6 @@ export const setupTimelineMockApiRoutes = async (
return route.continue();
});
await context.route('**/api/assets/*/video/playback*', async (route) => {
return route.fulfill({
status: 200,
headers: { 'content-type': 'video/mp4' },
body: MINIMAL_MP4_BUFFER,
});
});
await context.route('**/api/albums/**', async (route, request) => {
const albumsMatch = request.url().match(/\/api\/albums\/(?<albumId>[^/?]+)/);
if (albumsMatch) {
@@ -1,84 +0,0 @@
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 { assetViewerUtils } from '../timeline/utils';
import { setupAssetViewerFixture } from './utils';
test.describe.configure({ mode: 'parallel' });
test.describe('broken-asset responsiveness', () => {
const fixture = setupAssetViewerFixture(889);
let mockStack: MockStack;
test.beforeAll(async () => {
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
const brokenAssets = [
createMockStackAsset(fixture.adminUserId),
createMockStackAsset(fixture.adminUserId),
createMockStackAsset(fixture.adminUserId),
];
mockStack = createMockStack(primaryAssetDto, brokenAssets);
});
test.beforeEach(async ({ context }) => {
await setupBrokenAssetMockApiRoutes(context, mockStack);
});
test('broken asset in stack strip hides icon at small size', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const stackSlideshow = page.locator('#stack-slideshow');
await expect(stackSlideshow).toBeVisible();
const brokenAssets = stackSlideshow.locator('[data-broken-asset]');
await expect(brokenAssets.first()).toBeVisible();
await expect(brokenAssets).toHaveCount(mockStack.brokenAssetIds.size);
for (const brokenAsset of await brokenAssets.all()) {
await expect(brokenAsset.locator('svg')).not.toBeVisible();
}
});
test('broken asset in stack strip uses text-xs class', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const stackSlideshow = page.locator('#stack-slideshow');
await expect(stackSlideshow).toBeVisible();
const brokenAssets = stackSlideshow.locator('[data-broken-asset]');
await expect(brokenAssets.first()).toBeVisible();
for (const brokenAsset of await brokenAssets.all()) {
const messageSpan = brokenAsset.locator('span');
await expect(messageSpan).toHaveClass(/text-xs/);
}
});
test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => {
await context.route(
(url) => url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`),
async (route) => {
return route.fulfill({ status: 404 });
},
);
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await page.waitForSelector('#immich-asset-viewer');
const viewerBrokenAsset = page.locator('#immich-asset-viewer #broken-asset [data-broken-asset]');
await expect(viewerBrokenAsset).toBeVisible();
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
const messageSpan = viewerBrokenAsset.locator('span');
await expect(messageSpan).toHaveClass(/text-base/);
});
});
@@ -1,285 +0,0 @@
import { expect, Page, test } from '@playwright/test';
import { SeededRandom, selectRandom, TimelineAssetConfig } from 'src/ui/generators/timeline';
import {
createMockPeople,
FaceCreateCapture,
MockPerson,
setupFaceEditorMockApiRoutes,
} from 'src/ui/mock-network/face-editor-network';
import { assetViewerUtils } from '../timeline/utils';
import { setupAssetViewerFixture } from './utils';
const waitForSelectorTransition = async (page: Page) => {
await page.waitForFunction(
() => {
const selector = document.querySelector('#face-selector') as HTMLElement | null;
if (!selector) {
return false;
}
return selector.getAnimations({ subtree: false }).every((animation) => animation.playState === 'finished');
},
undefined,
{ timeout: 1000, polling: 50 },
);
};
const openFaceEditor = async (page: Page, asset: TimelineAssetConfig) => {
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.keyboard.press('i');
await page.locator('#detail-panel').waitFor({ state: 'visible' });
await page.getByLabel('Tag people').click();
await page.locator('#face-selector').waitFor({ state: 'visible' });
await waitForSelectorTransition(page);
};
test.describe.configure({ mode: 'parallel' });
test.describe('face-editor', () => {
const fixture = setupAssetViewerFixture(777);
const rng = new SeededRandom(777);
let mockPeople: MockPerson[];
let faceCreateCapture: FaceCreateCapture;
test.beforeAll(async () => {
mockPeople = createMockPeople(8);
});
test.beforeEach(async ({ context }) => {
faceCreateCapture = { requests: [] };
await setupFaceEditorMockApiRoutes(context, mockPeople, faceCreateCapture);
});
type ScreenRect = { top: number; left: number; width: number; height: number };
const getFaceBoxRect = async (page: Page): Promise<ScreenRect> => {
const dataEl = page.locator('#face-editor-data');
await expect(dataEl).toHaveAttribute('data-face-left', /^-?\d+/);
await expect(dataEl).toHaveAttribute('data-face-top', /^-?\d+/);
await expect(dataEl).toHaveAttribute('data-face-width', /^[1-9]/);
await expect(dataEl).toHaveAttribute('data-face-height', /^[1-9]/);
const canvasBox = await page.locator('#face-editor').boundingBox();
if (!canvasBox) {
throw new Error('Canvas element not found');
}
const left = Number(await dataEl.getAttribute('data-face-left'));
const top = Number(await dataEl.getAttribute('data-face-top'));
const width = Number(await dataEl.getAttribute('data-face-width'));
const height = Number(await dataEl.getAttribute('data-face-height'));
return {
top: canvasBox.y + top,
left: canvasBox.x + left,
width,
height,
};
};
const getSelectorRect = async (page: Page): Promise<ScreenRect> => {
const box = await page.locator('#face-selector').boundingBox();
if (!box) {
throw new Error('Face selector element not found');
}
return { top: box.y, left: box.x, width: box.width, height: box.height };
};
const computeOverlapArea = (a: ScreenRect, b: ScreenRect): number => {
const overlapX = Math.max(0, Math.min(a.left + a.width, b.left + b.width) - Math.max(a.left, b.left));
const overlapY = Math.max(0, Math.min(a.top + a.height, b.top + b.height) - Math.max(a.top, b.top));
return overlapX * overlapY;
};
const dragFaceBox = async (page: Page, deltaX: number, deltaY: number) => {
const faceBox = await getFaceBoxRect(page);
const centerX = faceBox.left + faceBox.width / 2;
const centerY = faceBox.top + faceBox.height / 2;
await page.mouse.move(centerX, centerY);
await page.mouse.down();
await page.mouse.move(centerX + deltaX, centerY + deltaY, { steps: 5 });
await page.mouse.up();
await page.waitForTimeout(300);
};
test('Face editor opens with person list', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await expect(page.locator('#face-selector')).toBeVisible();
await expect(page.locator('#face-editor')).toBeVisible();
for (const person of mockPeople) {
await expect(page.locator('#face-selector').getByText(person.name)).toBeVisible();
}
});
test('Search filters people by name', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const searchInput = page.locator('#face-selector input');
await searchInput.fill('Alice');
await expect(page.locator('#face-selector').getByText('Alice Johnson')).toBeVisible();
await expect(page.locator('#face-selector').getByText('Bob Smith')).toBeHidden();
await searchInput.clear();
for (const person of mockPeople) {
await expect(page.locator('#face-selector').getByText(person.name)).toBeVisible();
}
});
test('Search with no results shows empty message', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const searchInput = page.locator('#face-selector input');
await searchInput.fill('Nonexistent Person XYZ');
for (const person of mockPeople) {
await expect(page.locator('#face-selector').getByText(person.name)).toBeHidden();
}
});
test('Selecting a person shows confirmation dialog', 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();
});
test('Confirming tag calls createFace API and closes editor', 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('button', { name: /confirm/i }).click();
await expect(page.locator('#face-selector')).toBeHidden();
await expect(page.locator('#face-editor')).toBeHidden();
expect(faceCreateCapture.requests).toHaveLength(1);
expect(faceCreateCapture.requests[0].assetId).toBe(asset.id);
expect(faceCreateCapture.requests[0].personId).toBe(personToTag.id);
});
test('Cancel button closes face editor', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await expect(page.locator('#face-selector')).toBeVisible();
await expect(page.locator('#face-editor')).toBeVisible();
await page.getByRole('button', { name: /cancel/i }).click();
await expect(page.locator('#face-selector')).toBeHidden();
await expect(page.locator('#face-editor')).toBeHidden();
});
test('Selector does not overlap face box on initial open', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const faceBox = await getFaceBoxRect(page);
const selectorBox = await getSelectorRect(page);
const overlap = computeOverlapArea(faceBox, selectorBox);
expect(overlap).toBe(0);
});
test('Selector repositions without overlap after dragging face box down', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await dragFaceBox(page, 0, 150);
const faceBox = await getFaceBoxRect(page);
const selectorBox = await getSelectorRect(page);
const overlap = computeOverlapArea(faceBox, selectorBox);
expect(overlap).toBe(0);
});
test('Selector repositions without overlap after dragging face box right', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await dragFaceBox(page, 200, 0);
const faceBox = await getFaceBoxRect(page);
const selectorBox = await getSelectorRect(page);
const overlap = computeOverlapArea(faceBox, selectorBox);
expect(overlap).toBe(0);
});
test('Selector repositions without overlap after dragging face box to top-left corner', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await dragFaceBox(page, -300, -300);
const faceBox = await getFaceBoxRect(page);
const selectorBox = await getSelectorRect(page);
const overlap = computeOverlapArea(faceBox, selectorBox);
expect(overlap).toBe(0);
});
test('Selector repositions without overlap after dragging face box to bottom-right', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await dragFaceBox(page, 300, 300);
const faceBox = await getFaceBoxRect(page);
const selectorBox = await getSelectorRect(page);
const overlap = computeOverlapArea(faceBox, selectorBox);
expect(overlap).toBe(0);
});
test('Selector stays within viewport bounds', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const viewportSize = page.viewportSize()!;
const selectorBox = await getSelectorRect(page);
expect(selectorBox.top).toBeGreaterThanOrEqual(0);
expect(selectorBox.left).toBeGreaterThanOrEqual(0);
expect(selectorBox.top + selectorBox.height).toBeLessThanOrEqual(viewportSize.height);
expect(selectorBox.left + selectorBox.width).toBeLessThanOrEqual(viewportSize.width);
});
test('Selector stays within viewport after dragging to edge', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await dragFaceBox(page, -400, -400);
const viewportSize = page.viewportSize()!;
const selectorBox = await getSelectorRect(page);
expect(selectorBox.top).toBeGreaterThanOrEqual(0);
expect(selectorBox.left).toBeGreaterThanOrEqual(0);
expect(selectorBox.top + selectorBox.height).toBeLessThanOrEqual(viewportSize.height);
expect(selectorBox.left + selectorBox.width).toBeLessThanOrEqual(viewportSize.width);
});
test('Face box is draggable on the canvas', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const beforeDrag = await getFaceBoxRect(page);
await dragFaceBox(page, 100, 50);
const afterDrag = await getFaceBoxRect(page);
expect(afterDrag.left).toBeGreaterThan(beforeDrag.left + 50);
expect(afterDrag.top).toBeGreaterThan(beforeDrag.top + 20);
});
});
@@ -1,84 +0,0 @@
import { faker } from '@faker-js/faker';
import type { 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 { assetViewerUtils } from '../timeline/utils';
import { enableTagsPreference, ensureDetailPanelVisible, setupAssetViewerFixture } from './utils';
test.describe.configure({ mode: 'parallel' });
test.describe('asset-viewer stack', () => {
const fixture = setupAssetViewerFixture(888);
let mockStack: MockStack;
let primaryAssetDto: AssetResponseDto;
let secondAssetDto: AssetResponseDto;
test.beforeAll(async () => {
primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
primaryAssetDto.tags = [
{
id: faker.string.uuid(),
name: '1',
value: 'test/1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
secondAssetDto = createMockStackAsset(fixture.adminUserId);
secondAssetDto.tags = [
{
id: faker.string.uuid(),
name: '2',
value: 'test/2',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set());
});
test.beforeEach(async ({ context }) => {
await setupBrokenAssetMockApiRoutes(context, mockStack);
});
test('stack slideshow is visible', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const stackSlideshow = page.locator('#stack-slideshow');
await expect(stackSlideshow).toBeVisible();
const stackAssets = stackSlideshow.locator('[data-asset]');
await expect(stackAssets).toHaveCount(mockStack.assets.length);
});
test('tags of primary asset are visible', async ({ context, page }) => {
await enableTagsPreference(context);
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await ensureDetailPanelVisible(page);
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
await expect(tags.first()).toHaveText('test/1');
});
test('tags of second asset are visible', async ({ context, page }) => {
await enableTagsPreference(context);
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await ensureDetailPanelVisible(page);
const stackAssets = page.locator('#stack-slideshow [data-asset]');
await stackAssets.nth(1).click();
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
await expect(tags.first()).toHaveText('test/2');
});
});
-116
View File
@@ -1,116 +0,0 @@
import { faker } from '@faker-js/faker';
import type { AssetResponseDto } from '@immich/sdk';
import { BrowserContext, Page, test } from '@playwright/test';
import {
Changes,
createDefaultTimelineConfig,
generateTimelineData,
SeededRandom,
selectRandom,
TimelineAssetConfig,
TimelineData,
toAssetResponseDto,
} from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
import { utils } from 'src/utils';
export type AssetViewerTestFixture = {
adminUserId: string;
timelineRestData: TimelineData;
assets: TimelineAssetConfig[];
testContext: TimelineTestContext;
changes: Changes;
primaryAsset: TimelineAssetConfig;
primaryAssetDto: AssetResponseDto;
};
export function setupAssetViewerFixture(seed: number): AssetViewerTestFixture {
const rng = new SeededRandom(seed);
const testContext = new TimelineTestContext();
const fixture: AssetViewerTestFixture = {
adminUserId: undefined!,
timelineRestData: undefined!,
assets: [],
testContext,
changes: {
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
},
primaryAsset: undefined!,
primaryAssetDto: undefined!,
};
test.beforeAll(async () => {
test.fail(
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
);
utils.initSdk();
fixture.adminUserId = faker.string.uuid();
testContext.adminId = fixture.adminUserId;
fixture.timelineRestData = generateTimelineData({
...createDefaultTimelineConfig(),
ownerId: fixture.adminUserId,
});
for (const timeBucket of fixture.timelineRestData.buckets.values()) {
fixture.assets.push(...timeBucket);
}
fixture.primaryAsset = selectRandom(
fixture.assets.filter((a) => a.isImage),
rng,
);
fixture.primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, fixture.adminUserId);
await setupTimelineMockApiRoutes(context, fixture.timelineRestData, fixture.changes, fixture.testContext);
});
test.afterEach(() => {
fixture.testContext.slowBucket = false;
fixture.changes.albumAdditions = [];
fixture.changes.assetDeletions = [];
fixture.changes.assetArchivals = [];
fixture.changes.assetFavorites = [];
});
return fixture;
}
export async function ensureDetailPanelVisible(page: Page) {
await page.waitForSelector('#immich-asset-viewer');
const isVisible = await page.locator('#detail-panel').isVisible();
if (!isVisible) {
await page.keyboard.press('i');
await page.waitForSelector('#detail-panel');
}
}
export async function enableTagsPreference(context: BrowserContext) {
await context.route('**/users/me/preferences', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
albums: { defaultAssetOrder: 'desc' },
folders: { enabled: false, sidebarWeb: false },
memories: { enabled: true, duration: 5 },
people: { enabled: true, sidebarWeb: false },
sharedLinks: { enabled: true, sidebarWeb: false },
ratings: { enabled: false },
tags: { enabled: true, sidebarWeb: false },
emailNotifications: { enabled: true, albumInvite: true, albumUpdate: true },
download: { archiveSize: 4_294_967_296, includeEmbeddedVideos: false },
purchase: { showSupportBadge: true, hideBuyButtonUntil: '2100-02-12T00:00:00.000Z' },
cast: { gCastEnabled: false },
},
});
});
}
+4 -4
View File
@@ -871,8 +871,8 @@
"current_pin_code": "Current PIN code",
"current_server_address": "Current server address",
"custom_date": "Custom date",
"custom_locale": "Custom locale",
"custom_locale_description": "Format dates, times, and numbers based on the selected language and region",
"custom_locale": "Custom Locale",
"custom_locale_description": "Format dates and numbers based on the language and the region",
"custom_url": "Custom URL",
"cutoff_date_description": "Keep photos from the last…",
"cutoff_day": "{count, plural, one {day} other {days}}",
@@ -895,6 +895,8 @@
"deduplication_criteria_2": "Count of EXIF data",
"deduplication_info": "Deduplication Info",
"deduplication_info_description": "To automatically preselect assets and remove duplicates in bulk, we look at:",
"default_locale": "Default Locale",
"default_locale_description": "Format dates and numbers based on your browser locale",
"delete": "Delete",
"delete_action_confirmation_message": "Are you sure you want to delete this asset? This action will move the asset to the server's trash and will prompt if you want to delete it locally",
"delete_action_prompt": "{count} deleted",
@@ -2336,8 +2338,6 @@
"url": "URL",
"usage": "Usage",
"use_biometric": "Use biometric",
"use_browser_locale": "Use browser locale",
"use_browser_locale_description": "Format dates, times, and numbers based on your browser locale",
"use_current_connection": "Use current connection",
"use_custom_date_range": "Use custom date range instead",
"user": "User",
-72
View File
@@ -1,72 +0,0 @@
#!/usr/bin/env node
/**
* Computes SHA-256 hashes for inline <script> elements in app.html
* and updates the script-src CSP directive in svelte.config.js.
*
* SvelteKit's CSP hash mode only hashes inline content it generates itself,
* not the template content from app.html. This script fills that gap.
*
* Run this script whenever the inline scripts in app.html change.
*
* Usage: node misc/update-csp-hashes.mjs
*/
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(scriptDirectory, '..');
const appHtmlPath = join(repoRoot, 'web', 'src', 'app.html');
const configPath = join(repoRoot, 'web', 'svelte.config.js');
const appHtml = readFileSync(appHtmlPath, 'utf-8');
const scriptRegex = /<script[^>]*>([\s\S]*?)<\/script>/gi;
const hashes = [];
let match;
while ((match = scriptRegex.exec(appHtml)) !== null) {
const content = match[1];
const hash = createHash('sha256').update(content).digest('base64');
hashes.push(`sha256-${hash}`);
const preview = content.trim().slice(0, 60).replaceAll('\n', ' ');
console.log(`Found: ${preview}...`);
console.log(` Hash: sha256-${hash}`);
console.log();
}
if (hashes.length === 0) {
console.log('No inline <script> elements found in app.html');
process.exit(0);
}
let config = readFileSync(configPath, 'utf-8');
const scriptSrcRegex = /'script-src':\s*\[[\s\S]*?\]/;
const scriptSrcMatch = config.match(scriptSrcRegex);
if (!scriptSrcMatch) {
console.error("Could not find 'script-src' directive in svelte.config.js");
process.exit(1);
}
const existingEntries = [];
const entryRegex = /'([^']+)'/g;
let entryMatch;
while ((entryMatch = entryRegex.exec(scriptSrcMatch[0])) !== null) {
const value = entryMatch[1];
if (value === 'script-src' || value.startsWith('sha256-')) {
continue;
}
existingEntries.push(value);
}
const allEntries = [...existingEntries, ...hashes];
const formatted = allEntries.map((entry) => ` '${entry}'`).join(',\n');
const newScriptSrc = `'script-src': [\n${formatted},\n ]`;
config = config.replace(scriptSrcRegex, newScriptSrc);
writeFileSync(configPath, config);
console.log(`Updated svelte.config.js with ${hashes.length} script hash(es)`);
+2 -2
View File
@@ -16,8 +16,8 @@ config_roots = [
[tools]
node = "24.13.1"
flutter = "3.35.7"
pnpm = "10.30.3"
terragrunt = "0.99.4"
pnpm = "10.30.0"
terragrunt = "0.98.0"
opentofu = "1.11.4"
java = "21.0.2"
@@ -7,7 +7,6 @@ import android.database.Cursor
import androidx.exifinterface.media.ExifInterface
import android.os.Build
import android.os.Bundle
import android.os.ext.SdkExtensions
import android.provider.MediaStore
import android.util.Base64
import android.util.Log
@@ -79,22 +78,15 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
add(MediaStore.MediaColumns.IS_FAVORITE)
}
if (hasSpecialFormatColumn()) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
add(SPECIAL_FORMAT_COLUMN)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Fallback: read XMP from MediaStore to detect Motion Photos
// only needed if SPECIAL_FORMAT column isn't available
add(MediaStore.MediaColumns.XMP)
}
}.toTypedArray()
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
// _special_format requires S Extensions 21+
// https://developer.android.com/reference/android/provider/MediaStore.Files.FileColumns#SPECIAL_FORMAT
private fun hasSpecialFormatColumn(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 21
}
protected fun getCursor(
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -12,8 +11,7 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
@@ -131,16 +129,11 @@ class _BottomPanelState extends State<_BottomPanel> {
return;
}
final db = Drift();
try {
final dir = await getApplicationDocumentsDirectory();
for (final suffix in ['', '-wal', '-shm']) {
final file = File(path.join(dir.path, 'immich.sqlite$suffix'));
if (await file.exists()) {
await file.delete();
}
}
} catch (_) {
return;
await db.reset();
} finally {
await db.close();
}
if (mounted) {
@@ -18,10 +18,11 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@@ -52,6 +53,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
final _scrollController = ScrollController();
late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController);
final ValueNotifier<PhotoViewScaleState> _videoScaleStateNotifier = ValueNotifier(PhotoViewScaleState.initial);
double _snapOffset = 0.0;
@@ -77,6 +79,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_proxyScrollController.dispose();
_scaleBoundarySub?.cancel();
_eventSubscription?.cancel();
_videoScaleStateNotifier.dispose();
super.dispose();
}
@@ -246,14 +249,17 @@ class _AssetPageState extends ConsumerState<AssetPage> {
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
_isZoomed = switch (scaleState) {
PhotoViewScaleState.zoomedIn || PhotoViewScaleState.covering => true,
_ => false,
};
_isZoomed =
scaleState == PhotoViewScaleState.zoomedIn ||
scaleState == PhotoViewScaleState.covering ||
_videoScaleStateNotifier.value == PhotoViewScaleState.zoomedIn ||
_videoScaleStateNotifier.value == PhotoViewScaleState.covering;
_viewer.setZoomed(_isZoomed);
if (scaleState != PhotoViewScaleState.initial) {
if (_dragStart == null) _viewer.setControls(false);
ref.read(videoPlayerControlsProvider.notifier).pause();
return;
}
@@ -328,36 +334,31 @@ class _AssetPageState extends ConsumerState<AssetPage> {
);
}
final Size childSize;
if (displayAsset.width != null && displayAsset.height != null) {
final r = displayAsset.width! / displayAsset.height!;
final w = math.min(context.width, context.height * r);
childSize = Size(w, w / r);
} else {
childSize = Size(context.height, context.height);
}
return PhotoView.customChild(
key: Key(displayAsset.heroTag),
childSize: childSize,
filterQuality: FilterQuality.low,
onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd,
onDragCancel: _onDragCancel,
onTapUp: _onTapUp,
heroAttributes: heroAttributes,
filterQuality: FilterQuality.high,
basePosition: Alignment.center,
disableScaleGestures: showingDetails,
scaleStateChangedCallback: _onScaleStateChanged,
disableScaleGestures: true,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
tightMode: true,
onPageBuild: _onPageBuild,
enablePanAlways: true,
backgroundDecoration: backgroundDecoration,
child: NativeVideoViewer(
key: _NativeVideoViewerKey(displayAsset.heroTag),
asset: displayAsset,
scaleStateNotifier: _videoScaleStateNotifier,
disableScaleGestures: showingDetails,
image: Image(
image: getFullImageProvider(displayAsset, size: childSize),
image: getFullImageProvider(displayAsset, size: context.sizeData),
height: context.height,
width: context.width,
fit: BoxFit.contain,
alignment: Alignment.center,
),
@@ -18,8 +18,8 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widge
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
@@ -9,10 +9,11 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
@@ -25,6 +26,7 @@ import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@@ -49,10 +51,21 @@ bool _isCurrentAsset(BaseAsset asset, BaseAsset? currentAsset) {
class NativeVideoViewer extends HookConsumerWidget {
static final log = Logger('NativeVideoViewer');
final BaseAsset asset;
final bool showControls;
final int playbackDelayFactor;
final Widget image;
final ValueNotifier<PhotoViewScaleState>? scaleStateNotifier;
final bool disableScaleGestures;
const NativeVideoViewer({super.key, required this.asset, required this.image, this.playbackDelayFactor = 1});
const NativeVideoViewer({
super.key,
required this.asset,
required this.image,
this.showControls = true,
this.playbackDelayFactor = 1,
this.scaleStateNotifier,
this.disableScaleGestures = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -131,6 +144,7 @@ class NativeVideoViewer extends HookConsumerWidget {
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
final aspectRatio = useState<double?>(null);
useMemoized(() async {
if (!context.mounted || aspectRatio.value != null) {
return null;
@@ -306,6 +320,20 @@ class NativeVideoViewer extends HookConsumerWidget {
Timer(const Duration(milliseconds: 200), checkIfBuffering);
}
Size? videoContextSize(double? videoAspectRatio, BuildContext? context) {
Size? videoContextSize;
if (videoAspectRatio == null || context == null) {
return null;
}
final contextAspectRatio = context.width / context.height;
if (videoAspectRatio > contextAspectRatio) {
videoContextSize = Size(context.width, context.width / aspectRatio.value!);
} else {
videoContextSize = Size(context.height * aspectRatio.value!, context.height);
}
return videoContextSize;
}
ref.listen(currentAssetNotifier, (_, value) {
final playerController = controller.value;
if (playerController != null && value != asset) {
@@ -386,18 +414,29 @@ class NativeVideoViewer extends HookConsumerWidget {
}
});
return Stack(
children: [
// This remains under the video to avoid flickering
// For motion videos, this is the image portion of the asset
Center(child: image),
if (aspectRatio.value != null && !isCasting)
Visibility.maintain(
visible: isVisible.value,
child: NativeVideoPlayerView(onViewReady: initController),
),
const Center(child: VideoViewerControls()),
],
return SizedBox(
width: context.width,
height: context.height,
child: Stack(
children: [
// Hide thumbnail once video is visible to avoid it showing in background when zooming out on video.
if (!isVisible.value || controller.value == null) Center(child: image),
if (aspectRatio.value != null && !isCasting && isCurrent)
Visibility.maintain(
visible: isVisible.value,
child: PhotoView.customChild(
enableRotation: false,
disableScaleGestures: disableScaleGestures,
// Transparent to avoid a black flash when viewer becomes visible but video isn't loaded yet.
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
scaleStateChangedCallback: (state) => scaleStateNotifier?.value = state,
childSize: videoContextSize(aspectRatio.value, context),
child: NativeVideoPlayerView(onViewReady: initController),
),
),
if (showControls) const Center(child: VideoViewerControls()),
],
),
);
}
@@ -58,6 +58,7 @@ class DriftMemoryCard extends StatelessWidget {
child: NativeVideoViewer(
key: ValueKey(asset.id),
asset: asset,
showControls: false,
playbackDelayFactor: 2,
image: FullImage(asset, size: Size(context.width, context.height), fit: BoxFit.contain),
),
@@ -420,11 +420,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
Widget _buildChild() {
return widget.hasCustomChild
? SizedBox(
width: scaleBoundaries.childSize.width * scale,
height: scaleBoundaries.childSize.height * scale,
child: widget.customChild!,
)
? widget.customChild!
: Image(
key: widget.heroAttributes?.tag != null ? ObjectKey(widget.heroAttributes!.tag) : null,
image: widget.imageProvider!,
@@ -432,7 +428,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
gaplessPlayback: widget.gaplessPlayback ?? false,
filterQuality: widget.filterQuality,
width: scaleBoundaries.childSize.width * scale,
fit: BoxFit.contain,
fit: BoxFit.cover,
isAntiAlias: widget.filterQuality == FilterQuality.high,
);
}
+1 -1
View File
@@ -19,7 +19,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^24.10.14",
"@types/node": "^24.10.13",
"typescript": "^5.3.3"
},
"repository": {
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "2.5.6",
"description": "Monorepo for Immich",
"private": true,
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
"packageManager": "pnpm@10.30.0+sha512.2b5753de015d480eeb88f5b5b61e0051f05b4301808a82ec8b840c9d2adf7748eb352c83f5c1593ca703ff1017295bc3fdd3119abb9686efc96b9fcb18200937",
"engines": {
"pnpm": ">=10.0.0"
}
+908 -888
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -136,7 +136,7 @@
"@types/luxon": "^3.6.2",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^2.0.0",
"@types/node": "^24.10.14",
"@types/node": "^24.10.13",
"@types/nodemailer": "^7.0.0",
"@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5",
@@ -566,8 +566,6 @@ describe(AssetService.name, () => {
.file({ type: AssetFileType.Thumbnail })
.file({ type: AssetFileType.Preview })
.file({ type: AssetFileType.FullSize })
.file({ type: AssetFileType.Preview, isEdited: true })
.file({ type: AssetFileType.Thumbnail, isEdited: true })
.build();
mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);
+1 -1
View File
@@ -25,7 +25,7 @@ export const getAssetFiles = (files: AssetFile[]) => ({
editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }),
editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
editedThumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: true }),
editedThumbnailFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
});
export const addAssets = async (
-1
View File
@@ -404,7 +404,6 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
.$if(!!options.isNotInAlbum && (!options.albumIds || options.albumIds.length === 0), (qb) =>
qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('album_asset').whereRef('assetId', '=', 'asset.id')))),
)
.$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null))
.$if(!!options.withExif, withExifInner)
.$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople))
.$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null));
@@ -88,24 +88,4 @@ describe(SearchService.name, () => {
expect(result).toEqual({ total: 0 });
});
});
describe('withStacked option', () => {
it('should exclude stacked assets when withStacked is false', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { asset: primaryAsset } = await ctx.newAsset({ ownerId: user.id });
const { asset: stackedAsset } = await ctx.newAsset({ ownerId: user.id });
const { asset: unstackedAsset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newStack({ ownerId: user.id }, [primaryAsset.id, stackedAsset.id]);
const auth = factory.auth({ user: { id: user.id } });
const response = await sut.searchMetadata(auth, { withStacked: false });
expect(response.assets.items.length).toBe(1);
expect(response.assets.items[0].id).toBe(unstackedAsset.id);
});
});
});
@@ -0,0 +1,156 @@
type Config = IntersectionObserverActionProperties & {
observer?: IntersectionObserver;
};
type TrackedProperties = {
root?: Element | Document | null;
threshold?: number | number[];
top?: string;
right?: string;
bottom?: string;
left?: string;
};
type OnIntersectCallback = (entryOrElement: IntersectionObserverEntry | HTMLElement) => unknown;
type OnSeparateCallback = (element: HTMLElement) => unknown;
type IntersectionObserverActionProperties = {
key?: string;
disabled?: boolean;
/** Function to execute when the element leaves the viewport */
onSeparate?: OnSeparateCallback;
/** Function to execute when the element enters the viewport */
onIntersect?: OnIntersectCallback;
root?: Element | Document | null;
threshold?: number | number[];
top?: string;
right?: string;
bottom?: string;
left?: string;
};
type TaskKey = HTMLElement | string;
function isEquivalent(a: TrackedProperties, b: TrackedProperties) {
return (
a?.bottom === b?.bottom &&
a?.top === b?.top &&
a?.left === b?.left &&
a?.right == b?.right &&
a?.threshold === b?.threshold &&
a?.root === b?.root
);
}
const elementToConfig = new Map<TaskKey, Config>();
const observe = (key: HTMLElement | string, target: HTMLElement, properties: IntersectionObserverActionProperties) => {
if (!target.isConnected) {
elementToConfig.get(key)?.observer?.unobserve(target);
return;
}
const {
root,
threshold,
top = '0px',
right = '0px',
bottom = '0px',
left = '0px',
onSeparate,
onIntersect,
} = properties;
const rootMargin = `${top} ${right} ${bottom} ${left}`;
const observer = new IntersectionObserver(
(entries: IntersectionObserverEntry[]) => {
// This IntersectionObserver is limited to observing a single element, the one the
// action is attached to. If there are multiple entries, it means that this
// observer is being notified of multiple events that have occurred quickly together,
// and the latest element is the one we are interested in.
entries.sort((a, b) => a.time - b.time);
const latestEntry = entries.pop();
if (latestEntry?.isIntersecting) {
onIntersect?.(latestEntry);
} else {
onSeparate?.(target);
}
},
{
rootMargin,
threshold,
root,
},
);
observer.observe(target);
elementToConfig.set(key, { ...properties, observer });
};
function configure(key: HTMLElement | string, element: HTMLElement, properties: IntersectionObserverActionProperties) {
if (properties.disabled) {
const config = elementToConfig.get(key);
const { observer } = config || {};
observer?.unobserve(element);
elementToConfig.delete(key);
} else {
elementToConfig.set(key, properties);
observe(key, element, properties);
}
}
function _intersectionObserver(
key: HTMLElement | string,
element: HTMLElement,
properties: IntersectionObserverActionProperties,
) {
configure(key, element, properties);
return {
update(properties: IntersectionObserverActionProperties) {
const config = elementToConfig.get(key);
if (!config) {
return;
}
if (isEquivalent(config, properties)) {
return;
}
configure(key, element, properties);
},
destroy: () => {
const config = elementToConfig.get(key);
const { observer } = config || {};
observer?.unobserve(element);
elementToConfig.delete(key);
},
};
}
/**
* Monitors an element's visibility in the viewport and calls functions when it enters or leaves (based on a threshold).
* @param element
* @param properties One or multiple configurations for the IntersectionObserver(s)
* @returns
*/
export function intersectionObserver(
element: HTMLElement,
properties: IntersectionObserverActionProperties | IntersectionObserverActionProperties[],
) {
// svelte doesn't allow multiple use:action directives of the same kind on the same element,
// so accept an array when multiple configurations are needed.
if (Array.isArray(properties)) {
if (!properties.every((p) => p.key)) {
throw new Error('Multiple configurations must specify key');
}
const observers = properties.map((p) => _intersectionObserver(p.key as string, element, p));
return {
update: (properties: IntersectionObserverActionProperties[]) => {
for (const [i, props] of properties.entries()) {
observers[i].update(props);
}
},
destroy: () => {
for (const observer of observers) {
observer.destroy();
}
},
};
}
return _intersectionObserver(properties.key || element, element, properties);
}
+43
View File
@@ -0,0 +1,43 @@
export type OnResizeCallback = (resizeEvent: { target: HTMLElement; width: number; height: number }) => void;
let observer: ResizeObserver;
let callbacks: WeakMap<HTMLElement, OnResizeCallback>;
/**
* Installs a resizeObserver on the given element - when the element changes
* size, invokes a callback function with the width/height. Intended as a
* replacement for bind:clientWidth and bind:clientHeight in svelte4 which use
* an iframe to measure the size of the element, which can be bad for
* performance and memory usage. In svelte5, they adapted bind:clientHeight and
* bind:clientWidth to use an internal resize observer.
*
* TODO: When svelte5 is ready, go back to bind:clientWidth and
* bind:clientHeight.
*/
export function resizeObserver(element: HTMLElement, onResize: OnResizeCallback) {
if (!observer) {
callbacks = new WeakMap();
observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const onResize = callbacks.get(entry.target as HTMLElement);
if (onResize) {
onResize({
target: entry.target as HTMLElement,
width: entry.borderBoxSize[0].inlineSize,
height: entry.borderBoxSize[0].blockSize,
});
}
}
});
}
callbacks.set(element, onResize);
observer.observe(element);
return {
destroy: () => {
callbacks.delete(element);
observer.unobserve(element);
},
};
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { onDestroy, untrack } from 'svelte';
import type { HTMLImgAttributes } from 'svelte/elements';
@@ -28,7 +28,7 @@
onDestroy(() => {
destroyed = true;
if (capturedSource !== undefined) {
cancelImageUrl(capturedSource);
imageManager.cancelPreloadUrl(capturedSource);
}
});
@@ -4,6 +4,7 @@
import { SettingInputFieldType } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { getQueueName } from '$lib/utils';
import { QueueName, type SystemConfigJobDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -29,27 +30,6 @@
function isSystemConfigJobDto(jobName: string): jobName is keyof SystemConfigJobDto {
return jobName in configToEdit.job;
}
const queueTitles: Record<QueueName, string> = $derived({
[QueueName.ThumbnailGeneration]: $t('admin.thumbnail_generation_job'),
[QueueName.MetadataExtraction]: $t('admin.metadata_extraction_job'),
[QueueName.Sidecar]: $t('admin.sidecar_job'),
[QueueName.SmartSearch]: $t('admin.machine_learning_smart_search'),
[QueueName.DuplicateDetection]: $t('admin.machine_learning_duplicate_detection'),
[QueueName.FaceDetection]: $t('admin.face_detection'),
[QueueName.FacialRecognition]: $t('admin.machine_learning_facial_recognition'),
[QueueName.VideoConversion]: $t('admin.video_conversion_job'),
[QueueName.StorageTemplateMigration]: $t('admin.storage_template_migration'),
[QueueName.Migration]: $t('admin.migration_job'),
[QueueName.BackgroundTask]: $t('admin.background_task_job'),
[QueueName.Search]: $t('search'),
[QueueName.Library]: $t('external_libraries'),
[QueueName.Notifications]: $t('notifications'),
[QueueName.BackupDatabase]: $t('admin.backup_database'),
[QueueName.Ocr]: $t('admin.machine_learning_ocr'),
[QueueName.Workflow]: $t('workflows'),
[QueueName.Editor]: $t('editor'),
});
</script>
<div>
@@ -61,7 +41,7 @@
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
{disabled}
label={$t('admin.job_concurrency', { values: { job: queueTitles[queueName] } })}
label={$t('admin.job_concurrency', { values: { job: $getQueueName(queueName) } })}
description=""
bind:value={configToEdit.job[queueName].concurrency}
required={true}
@@ -70,7 +50,7 @@
{:else}
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.job_concurrency', { values: { job: queueTitles[queueName] } })}
label={$t('admin.job_concurrency', { values: { job: $getQueueName(queueName) } })}
description=""
value={1}
disabled={true}
@@ -11,38 +11,85 @@
let { options }: Props = $props();
const getExampleDate = () => DateTime.fromISO('2022-02-15T20:03:05.250Z', { locale: $locale });
const getLuxonExample = (format: string) => {
return DateTime.fromISO('2022-02-15T20:03:05.250Z', { locale: $locale }).toFormat(format);
};
</script>
{#snippet example(title: string, options: Array<string>)}
<div>
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{title}</Text>
<ul>
{#each options as format, index (index)}
<li>{`{{${format}}} - ${getExampleDate().toFormat(format)}`}</li>
{/each}
</ul>
</div>
{/snippet}
<Text size="small">{$t('date_and_time')}</Text>
<!-- eslint-disable svelte/no-useless-mustaches -->
<Card class="mt-2 text-sm bg-light-50 shadow-none">
<CardHeader>
<Text class="mb-1">{$t('admin.storage_template_date_time_description')}</Text>
<Text color="primary"
>{$t('admin.storage_template_date_time_sample', { values: { date: getExampleDate().toISO() } })}</Text
>{$t('admin.storage_template_date_time_sample', { values: { date: '2022-02-15T20:03:05.250+00:00' } })}</Text
>
</CardHeader>
<CardBody>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-3 md:grid-cols-4">
{@render example($t('year'), options.yearOptions)}
{@render example($t('month'), options.monthOptions)}
{@render example($t('week'), options.weekOptions)}
{@render example($t('day'), options.dayOptions)}
{@render example($t('hour'), options.hourOptions)}
{@render example($t('minute'), options.minuteOptions)}
{@render example($t('second'), options.secondOptions)}
<div>
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('year')}</Text>
<ul>
{#each options.yearOptions as yearFormat, index (index)}
<li>{'{{'}{yearFormat}{'}}'} - {getLuxonExample(yearFormat)}</li>
{/each}
</ul>
</div>
<div>
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('month')}</Text>
<ul>
{#each options.monthOptions as monthFormat, index (index)}
<li>{'{{'}{monthFormat}{'}}'} - {getLuxonExample(monthFormat)}</li>
{/each}
</ul>
</div>
<div>
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('week')}</Text>
<ul>
{#each options.weekOptions as weekFormat, index (index)}
<li>{'{{'}{weekFormat}{'}}'} - {getLuxonExample(weekFormat)}</li>
{/each}
</ul>
</div>
<div>
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('day')}</Text>
<ul>
{#each options.dayOptions as dayFormat, index (index)}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each}
</ul>
</div>
<div>
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('hour')}</Text>
<ul>
{#each options.hourOptions as dayFormat, index (index)}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each}
</ul>
</div>
<div>
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('minute')}</Text>
<ul>
{#each options.minuteOptions as dayFormat, index (index)}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each}
</ul>
</div>
<div>
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('second')}</Text>
<ul>
{#each options.secondOptions as dayFormat, index (index)}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each}
</ul>
</div>
</div>
</CardBody>
</Card>
@@ -18,7 +18,6 @@
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { languageManager } from '$lib/managers/language-manager.svelte';
import { Route } from '$lib/route';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions, handleReplaceAsset } from '$lib/services/asset.service';
@@ -37,7 +36,6 @@
import { ActionButton, CommandPaletteDefaultProvider, type ActionItem } from '@immich/ui';
import {
mdiArrowLeft,
mdiArrowRight,
mdiCompare,
mdiDotsVertical,
mdiImageSearch,
@@ -86,7 +84,7 @@
const Close: ActionItem = $derived({
title: $t('go_back'),
type: $t('assets'),
icon: languageManager.rtl ? mdiArrowRight : mdiArrowLeft,
icon: mdiArrowLeft,
$if: () => !!onClose,
onAction: () => onClose?.(),
shortcuts: [{ key: 'Escape' }],
@@ -30,6 +30,7 @@
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetTypeEnum,
getAllAlbums,
getAssetInfo,
getStack,
type AlbumResponseDto,
@@ -104,6 +105,7 @@
const asset = $derived(cursor.current);
const nextAsset = $derived(cursor.nextAsset);
const previousAsset = $derived(cursor.previousAsset);
let appearsInAlbums: AlbumResponseDto[] = $state([]);
let sharedLink = getSharedLink();
let previewStackedAsset: AssetResponseDto | undefined = $state();
let fullscreenElement = $state<Element>();
@@ -145,7 +147,7 @@
}
};
onMount(() => {
onMount(async () => {
syncAssetViewerOpenClass(true);
unsubscribes.push(
slideshowState.subscribe((value) => {
@@ -164,6 +166,8 @@
}
}),
);
await onAlbumAddAssets();
});
onDestroy(() => {
@@ -176,6 +180,18 @@
syncAssetViewerOpenClass(false);
});
const onAlbumAddAssets = async () => {
if (authManager.isSharedLink) {
return;
}
try {
appearsInAlbums = await getAllAlbums({ assetId: asset.id });
} catch (error) {
console.error('Error getting album that asset belong to', error);
}
};
const closeViewer = () => {
onClose?.(asset);
};
@@ -347,6 +363,7 @@
const refresh = async () => {
await refreshStack();
await onAlbumAddAssets();
ocrManager.clear();
if (!sharedLink) {
if (previewStackedAsset) {
@@ -424,7 +441,7 @@
</script>
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
<OnEvents {onAssetReplace} {onAssetUpdate} />
<OnEvents {onAssetReplace} {onAssetUpdate} {onAlbumAddAssets} />
<svelte:document bind:fullscreenElement />
@@ -569,7 +586,7 @@
>
{#if showDetailPanel}
<div class="w-90 h-full">
<DetailPanel {asset} currentAlbum={album} />
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} />
</div>
{:else if assetViewerManager.isShowEditor}
<div class="w-100 h-full">
@@ -17,16 +17,9 @@
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { delay, getDimensions } from '$lib/utils/asset-utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { getParentPath } from '$lib/utils/tree-utils';
import {
AssetMediaSize,
getAllAlbums,
getAssetInfo,
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, modalManager, Text } from '@immich/ui';
import {
mdiCalendar,
@@ -45,16 +38,16 @@
import { slide } from 'svelte/transition';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import PersonSidePanel from '../faces-page/person-side-panel.svelte';
import OnEvents from '../OnEvents.svelte';
import UserAvatar from '../shared-components/user-avatar.svelte';
import AlbumListItemDetails from './album-list-item-details.svelte';
interface Props {
asset: AssetResponseDto;
albums?: AlbumResponseDto[];
currentAlbum?: AlbumResponseDto | null;
}
let { asset, currentAlbum = null }: Props = $props();
let { asset, albums = [], currentAlbum = null }: Props = $props();
let showAssetPath = $state(false);
let showEditFaces = $state(false);
@@ -81,33 +74,14 @@
let previousId: string | undefined = $state();
let previousRoute = $derived(currentAlbum?.id ? Route.viewAlbum(currentAlbum) : Route.photos());
const refreshAlbums = async () => {
if (authManager.isSharedLink) {
return [];
}
try {
return await getAllAlbums({ assetId: asset.id });
} catch (error) {
handleError(error, 'Error getting asset album membership');
return [];
}
};
let albums = $derived(refreshAlbums());
$effect(() => {
if (!previousId) {
previousId = asset.id;
return;
}
if (asset.id === previousId) {
return;
if (asset.id !== previousId) {
showEditFaces = false;
previousId = asset.id;
}
showEditFaces = false;
previousId = asset.id;
});
const getMegapixel = (width: number, height: number): number | undefined => {
@@ -145,8 +119,6 @@
};
</script>
<OnEvents onAlbumAddAssets={() => (albums = refreshAlbums())} />
<section class="relative p-2">
<div class="flex place-items-center gap-2">
<IconButton
@@ -530,39 +502,37 @@
</section>
{/if}
{#await albums then albums}
{#if albums.length > 0}
<section class="px-6 py-6 dark:text-immich-dark-fg">
<div class="pb-4">
<Text size="small" color="muted">{$t('appears_in')}</Text>
</div>
{#each albums as album (album.id)}
<a href={Route.viewAlbum(album)}>
<div class="flex gap-4 pt-2 hover:cursor-pointer items-center">
<div>
<img
alt={album.albumName}
class="h-12.5 w-12.5 rounded object-cover"
src={album.albumThumbnailAssetId &&
getAssetMediaUrl({ id: album.albumThumbnailAssetId, size: AssetMediaSize.Preview })}
draggable="false"
/>
</div>
{#if albums.length > 0}
<section class="px-6 py-6 dark:text-immich-dark-fg">
<div class="pb-4">
<Text size="small" color="muted">{$t('appears_in')}</Text>
</div>
{#each albums as album (album.id)}
<a href={Route.viewAlbum(album)}>
<div class="flex gap-4 pt-2 hover:cursor-pointer items-center">
<div>
<img
alt={album.albumName}
class="h-12.5 w-12.5 rounded object-cover"
src={album.albumThumbnailAssetId &&
getAssetMediaUrl({ id: album.albumThumbnailAssetId, size: AssetMediaSize.Preview })}
draggable="false"
/>
</div>
<div class="mb-auto mt-auto">
<p class="dark:text-immich-dark-primary">{album.albumName}</p>
<div class="flex flex-col gap-0 text-sm">
<div>
<AlbumListItemDetails {album} />
</div>
<div class="mb-auto mt-auto">
<p class="dark:text-immich-dark-primary">{album.albumName}</p>
<div class="flex flex-col gap-0 text-sm">
<div>
<AlbumListItemDetails {album} />
</div>
</div>
</div>
</a>
{/each}
</section>
{/if}
{/await}
</div>
</a>
{/each}
</section>
{/if}
{#if $preferences?.tags?.enabled}
<section class="relative px-2 pb-12 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
@@ -134,7 +134,7 @@
></div>
{/if}
</Button>
<span class="text-sm text-white">{ratio.label}</span>
<span class="text-sm text-white text-left">{ratio.label}</span>
</HStack>
{/each}
</div>
@@ -3,12 +3,10 @@
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { getContentMetrics, getNaturalSize } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error';
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, Input, modalManager, toastManager } from '@immich/ui';
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
import { clamp } from 'lodash-es';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -25,12 +23,10 @@
let canvas: Canvas | undefined = $state();
let faceRect: Rect | undefined = $state();
let faceSelectorEl: HTMLDivElement | undefined = $state();
let scrollableListEl: HTMLDivElement | undefined = $state();
let page = $state(1);
let candidates = $state<PersonResponseDto[]>([]);
let searchTerm = $state('');
let faceBoxPosition = $state({ left: 0, top: 0, width: 0, height: 0 });
let filteredCandidates = $derived(
searchTerm
@@ -82,13 +78,17 @@
});
$effect(() => {
const metrics = getContentMetrics(htmlElement);
const { actualWidth, actualHeight } = getContainedSize(htmlElement);
const offsetArea = {
width: (containerWidth - actualWidth) / 2,
height: (containerHeight - actualHeight) / 2,
};
const imageBoundingBox = {
top: metrics.offsetY,
left: metrics.offsetX,
width: metrics.contentWidth,
height: metrics.contentHeight,
top: offsetArea.height,
left: offsetArea.width,
width: containerWidth - offsetArea.width * 2,
height: containerHeight - offsetArea.height * 2,
};
if (!canvas) {
@@ -113,6 +113,32 @@
positionFaceSelector();
});
const getContainedSize = (
img: HTMLImageElement | HTMLVideoElement,
): { actualWidth: number; actualHeight: number } => {
if (img instanceof HTMLImageElement) {
const ratio = img.naturalWidth / img.naturalHeight;
let actualWidth = img.height * ratio;
let actualHeight = img.height;
if (actualWidth > img.width) {
actualWidth = img.width;
actualHeight = img.width / ratio;
}
return { actualWidth, actualHeight };
} else if (img instanceof HTMLVideoElement) {
const ratio = img.videoWidth / img.videoHeight;
let actualWidth = img.clientHeight * ratio;
let actualHeight = img.clientHeight;
if (actualWidth > img.clientWidth) {
actualWidth = img.clientWidth;
actualHeight = img.clientWidth / ratio;
}
return { actualWidth, actualHeight };
}
return { actualWidth: 0, actualHeight: 0 };
};
const cancel = () => {
isFaceEditMode.value = false;
};
@@ -131,80 +157,69 @@
}
};
const MAX_LIST_HEIGHT = 250;
const positionFaceSelector = () => {
if (!faceRect || !faceSelectorEl || !scrollableListEl) {
if (!faceRect || !faceSelectorEl) {
return;
}
const gap = 15;
const padding = faceRect.padding ?? 0;
const rawBox = faceRect.getBoundingRect();
const faceBox = {
left: rawBox.left - padding,
top: rawBox.top - padding,
width: rawBox.width + padding * 2,
height: rawBox.height + padding * 2,
};
const rect = faceRect.getBoundingRect();
const selectorWidth = faceSelectorEl.offsetWidth;
const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight;
const listHeight = Math.min(MAX_LIST_HEIGHT, containerHeight - gap * 2 - chromeHeight);
const selectorHeight = listHeight + chromeHeight;
const selectorHeight = faceSelectorEl.offsetHeight;
const clampTop = (top: number) => clamp(top, gap, containerHeight - selectorHeight - gap);
const clampLeft = (left: number) => clamp(left, gap, containerWidth - selectorWidth - gap);
const spaceAbove = rect.top;
const spaceBelow = containerHeight - (rect.top + rect.height);
const spaceLeft = rect.left;
const spaceRight = containerWidth - (rect.left + rect.width);
const overlapArea = (position: { top: number; left: number }) => {
const selectorRight = position.left + selectorWidth;
const selectorBottom = position.top + selectorHeight;
const faceRight = faceBox.left + faceBox.width;
const faceBottom = faceBox.top + faceBox.height;
let top, left;
const overlapX = Math.max(0, Math.min(selectorRight, faceRight) - Math.max(position.left, faceBox.left));
const overlapY = Math.max(0, Math.min(selectorBottom, faceBottom) - Math.max(position.top, faceBox.top));
return overlapX * overlapY;
};
const faceBottom = faceBox.top + faceBox.height;
const faceRight = faceBox.left + faceBox.width;
const positions = [
{ top: clampTop(faceBottom + gap), left: clampLeft(faceBox.left) },
{ top: clampTop(faceBox.top - selectorHeight - gap), left: clampLeft(faceBox.left) },
{ top: clampTop(faceBox.top), left: clampLeft(faceRight + gap) },
{ top: clampTop(faceBox.top), left: clampLeft(faceBox.left - selectorWidth - gap) },
];
let bestPosition = positions[0];
let leastOverlap = Infinity;
for (const position of positions) {
const overlap = overlapArea(position);
if (overlap < leastOverlap) {
leastOverlap = overlap;
bestPosition = position;
if (overlap === 0) {
break;
}
}
if (
spaceBelow >= selectorHeight ||
(spaceBelow >= spaceAbove && spaceBelow >= spaceLeft && spaceBelow >= spaceRight)
) {
top = rect.top + rect.height + 15;
left = rect.left;
} else if (
spaceAbove >= selectorHeight ||
(spaceAbove >= spaceBelow && spaceAbove >= spaceLeft && spaceAbove >= spaceRight)
) {
top = rect.top - selectorHeight - 15;
left = rect.left;
} else if (
spaceRight >= selectorWidth ||
(spaceRight >= spaceLeft && spaceRight >= spaceAbove && spaceRight >= spaceBelow)
) {
top = rect.top;
left = rect.left + rect.width + 15;
} else {
top = rect.top;
left = rect.left - selectorWidth - 15;
}
faceSelectorEl.style.top = `${bestPosition.top}px`;
faceSelectorEl.style.left = `${bestPosition.left}px`;
scrollableListEl.style.height = `${listHeight}px`;
faceBoxPosition = { left: faceBox.left, top: faceBox.top, width: faceBox.width, height: faceBox.height };
if (left + selectorWidth > containerWidth) {
left = containerWidth - selectorWidth - 15;
}
if (left < 0) {
left = 15;
}
if (top + selectorHeight > containerHeight) {
top = containerHeight - selectorHeight - 15;
}
if (top < 0) {
top = 15;
}
faceSelectorEl.style.top = `${top}px`;
faceSelectorEl.style.left = `${left}px`;
};
$effect(() => {
const rect = faceRect;
if (rect) {
rect.on('moving', positionFaceSelector);
rect.on('scaling', positionFaceSelector);
return () => {
rect.off('moving', positionFaceSelector);
rect.off('scaling', positionFaceSelector);
};
if (faceRect) {
faceRect.on('moving', positionFaceSelector);
faceRect.on('scaling', positionFaceSelector);
}
});
@@ -214,22 +229,48 @@
}
const { left, top, width, height } = faceRect.getBoundingRect();
const metrics = getContentMetrics(htmlElement);
const natural = getNaturalSize(htmlElement);
const { actualWidth, actualHeight } = getContainedSize(htmlElement);
const scaleX = natural.width / metrics.contentWidth;
const scaleY = natural.height / metrics.contentHeight;
const imageX = (left - metrics.offsetX) * scaleX;
const imageY = (top - metrics.offsetY) * scaleY;
return {
imageWidth: natural.width,
imageHeight: natural.height,
x: Math.floor(imageX),
y: Math.floor(imageY),
width: Math.floor(width * scaleX),
height: Math.floor(height * scaleY),
const offsetArea = {
width: (containerWidth - actualWidth) / 2,
height: (containerHeight - actualHeight) / 2,
};
const x1Coeff = (left - offsetArea.width) / actualWidth;
const y1Coeff = (top - offsetArea.height) / actualHeight;
const x2Coeff = (left + width - offsetArea.width) / actualWidth;
const y2Coeff = (top + height - offsetArea.height) / actualHeight;
// transpose to the natural image location
if (htmlElement instanceof HTMLImageElement) {
const x1 = x1Coeff * htmlElement.naturalWidth;
const y1 = y1Coeff * htmlElement.naturalHeight;
const x2 = x2Coeff * htmlElement.naturalWidth;
const y2 = y2Coeff * htmlElement.naturalHeight;
return {
imageWidth: htmlElement.naturalWidth,
imageHeight: htmlElement.naturalHeight,
x: Math.floor(x1),
y: Math.floor(y1),
width: Math.floor(x2 - x1),
height: Math.floor(y2 - y1),
};
} else if (htmlElement instanceof HTMLVideoElement) {
const x1 = x1Coeff * htmlElement.videoWidth;
const y1 = y1Coeff * htmlElement.videoHeight;
const x2 = x2Coeff * htmlElement.videoWidth;
const y2 = y2Coeff * htmlElement.videoHeight;
return {
imageWidth: htmlElement.videoWidth,
imageHeight: htmlElement.videoHeight,
x: Math.floor(x1),
y: Math.floor(y1),
width: Math.floor(x2 - x1),
height: Math.floor(y2 - y1),
};
}
};
const tagFace = async (person: PersonResponseDto) => {
@@ -267,20 +308,13 @@
};
</script>
<div
id="face-editor-data"
class="absolute start-0 top-0 z-5 h-full w-full overflow-hidden"
data-face-left={faceBoxPosition.left}
data-face-top={faceBoxPosition.top}
data-face-width={faceBoxPosition.width}
data-face-height={faceBoxPosition.height}
>
<div class="absolute start-0 top-0">
<canvas bind:this={canvasEl} id="face-editor" class="absolute top-0 start-0"></canvas>
<div
id="face-selector"
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="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"
>
<p class="text-center text-sm">{$t('select_person_to_tag')}</p>
@@ -288,7 +322,7 @@
<Input placeholder={$t('search_people')} bind:value={searchTerm} size="tiny" />
</div>
<div bind:this={scrollableListEl} class="h-62.5 overflow-y-auto mt-2">
<div class="h-62.5 overflow-y-auto mt-2">
{#if filteredCandidates.length > 0}
<div class="mt-2 rounded-lg">
{#each filteredCandidates as person (person.id)}
@@ -5,7 +5,7 @@
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { calculateBoundingBoxMatrix, getOcrBoundingBoxes, type Point } from '$lib/utils/ocr-utils';
import { calculateBoundingBoxMatrix, getOcrBoundingBoxesAtSize, type Point } from '$lib/utils/ocr-utils';
import {
EquirectangularAdapter,
Viewer,
@@ -127,11 +127,9 @@
markersPlugin.clearMarkers();
}
const boxes = getOcrBoundingBoxes(ocrData, {
contentWidth: viewer.state.textureData.panoData.croppedWidth,
contentHeight: viewer.state.textureData.panoData.croppedHeight,
offsetX: 0,
offsetY: 0,
const boxes = getOcrBoundingBoxesAtSize(ocrData, {
width: viewer.state.textureData.panoData.croppedWidth,
height: viewer.state.textureData.panoData.croppedHeight,
});
for (const [index, box] of boxes.entries()) {
@@ -15,7 +15,6 @@
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { type ContentMetrics, getContentMetrics } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error';
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
import { getBoundingBox } from '$lib/utils/people-utils';
@@ -53,7 +52,6 @@
let imageLoaded: boolean = $state(false);
let originalImageLoaded: boolean = $state(false);
let imageError: boolean = $state(false);
let visibleImageReady: boolean = $state(false);
let loader = $state<HTMLImageElement>();
@@ -69,23 +67,11 @@
$boundingBoxesArray = [];
});
const overlayMetrics = $derived.by((): ContentMetrics => {
if (!assetViewerManager.imgRef || !visibleImageReady) {
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
}
const { contentWidth, contentHeight, offsetX, offsetY } = getContentMetrics(assetViewerManager.imgRef);
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
return {
contentWidth: contentWidth * currentZoom,
contentHeight: contentHeight * currentZoom,
offsetX: offsetX * currentZoom + currentPositionX,
offsetY: offsetY * currentZoom + currentPositionY,
};
});
let ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlayMetrics) : []);
let ocrBoxes = $derived(
ocrManager.showOverlay && assetViewerManager.imgRef
? getOcrBoundingBoxes(ocrManager.data, assetViewerManager.zoomState, assetViewerManager.imgRef)
: [],
);
let isOcrActive = $derived(ocrManager.showOverlay);
@@ -173,7 +159,7 @@
imageError = imageLoaded = true;
};
onDestroy(() => imageManager.cancel(asset, targetImageSize));
onDestroy(() => imageManager.cancelPreloadUrl(imageLoaderUrl));
let imageLoaderUrl = $derived(
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || assetViewerManager.zoom > 1 }),
@@ -190,7 +176,6 @@
imageLoaded = false;
originalImageLoaded = false;
imageError = false;
visibleImageReady = false;
});
}
lastUrl = imageLoaderUrl;
@@ -241,14 +226,14 @@
<img
bind:this={assetViewerManager.imgRef}
src={imageLoaderUrl}
onload={() => (visibleImageReady = true)}
alt={$getAltText(toTimelineAsset(asset))}
class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain'
: slideshowLookCssMapping[$slideshowLook]}"
draggable="false"
/>
{#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox (boundingbox.id)}
<!-- eslint-disable-next-line svelte/require-each-key -->
{#each getBoundingBox($boundingBoxesArray, assetViewerManager.zoomState, assetViewerManager.imgRef) as boundingbox}
<div
class="absolute border-solid border-white border-3 rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
@@ -2,7 +2,6 @@
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import ProgressBar from '$lib/components/shared-components/progress-bar/progress-bar.svelte';
import { ProgressBarStatus } from '$lib/constants';
import { languageManager } from '$lib/managers/language-manager.svelte';
import SlideshowSettingsModal from '$lib/modals/SlideshowSettingsModal.svelte';
import { SlideshowNavigation, slideshowStore } from '$lib/stores/slideshow.store';
import { AssetTypeEnum } from '@immich/sdk';
@@ -200,7 +199,7 @@
variant="ghost"
shape="round"
color="secondary"
icon={languageManager.rtl ? mdiChevronRight : mdiChevronLeft}
icon={mdiChevronLeft}
onclick={onPrevious}
aria-label={$t('previous')}
/>
@@ -208,7 +207,7 @@
variant="ghost"
shape="round"
color="secondary"
icon={languageManager.rtl ? mdiChevronLeft : mdiChevronRight}
icon={mdiChevronRight}
onclick={onNext}
aria-label={$t('next')}
/>
@@ -15,18 +15,15 @@
</script>
<div
data-broken-asset
class={[
'@container flex flex-col overflow-hidden max-h-full max-w-full justify-center items-center bg-gray-100/40 dark:bg-gray-700/40 dark:text-gray-100 p-4',
'flex flex-col overflow-hidden max-h-full max-w-full justify-center items-center bg-gray-100/40 dark:bg-gray-700/40 dark:text-gray-100 p-4',
className,
]}
style:width
style:height
>
<div class="hidden @min-[75px]:block">
<Icon icon={mdiImageBrokenVariant} size="7em" class="max-w-full min-w-6 min-h-6" />
</div>
<Icon icon={mdiImageBrokenVariant} size="7em" class="max-w-full" />
{#if !hideMessage}
<span class="text-center text-xs @min-[100px]:text-sm @min-[150px]:text-base">{$t('error_loading_image')}</span>
<span class="text-center">{$t('error_loading_image')}</span>
{/if}
</div>
@@ -283,7 +283,8 @@
</div>
{:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver}
<!-- GIF -->
<div class="absolute h-full w-full pointer-events-none">
<div class="absolute top-0 h-full w-full pointer-events-none">
<div class="absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%]"></div>
<ImageThumbnail
class={imageClass}
{brokenAssetClass}
@@ -293,6 +294,11 @@
heightStyle="{height}px"
curve={selected}
/>
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pe-2 pt-2">
<Icon data-icon-playable-pause icon={mdiMotionPauseOutline} size="24" />
</span>
</div>
</div>
{/if}
@@ -312,7 +318,7 @@
<!-- icon overlay -->
<div class="z-2 absolute inset-0">
<!-- Gradient overlay on hover -->
{#if !usingMobileDevice && !disabled && !asset.isVideo}
{#if !usingMobileDevice && !disabled}
<div
class={[
'absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100 ',
@@ -348,7 +354,7 @@
{/if}
{#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
<div class={['z-2 absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
<div class={['z-2 absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
<Icon data-icon-archive icon={mdiArchiveArrowDownOutline} size="24" class="text-white" />
</div>
{/if}
@@ -356,7 +362,7 @@
{#if asset.isImage && asset.projectionType === ProjectionType.EQUIRECTANGULAR}
<div class="z-2 absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pe-2 pt-2">
<Icon icon={mdiRotate360} size="24" />
<Icon data-icon-equirectangular icon={mdiRotate360} size="24" />
</span>
</div>
{/if}
@@ -364,7 +370,7 @@
{#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')}
<div class="z-2 absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pe-2 pt-2">
<Icon icon={mouseOver ? mdiMotionPauseOutline : mdiFileGifBox} size="24" />
<Icon data-icon-playable icon={mdiFileGifBox} size="24" />
</span>
</div>
{/if}
@@ -379,7 +385,7 @@
>
<span class="pe-2 pt-2 flex place-items-center gap-1">
<p>{asset.stack.assetCount.toLocaleString($locale)}</p>
<Icon icon={mdiCameraBurst} size="24" />
<Icon data-icon-stack icon={mdiCameraBurst} size="24" />
</span>
</div>
{/if}
@@ -107,7 +107,7 @@
<span class="pe-2 pt-2 drop-shadow-[1px_1px_6px_rgb(0_0_0)]" onmouseenter={onMouseEnter} onmouseleave={onMouseLeave}>
{#if enablePlayback}
{#if loading}
<LoadingSpinner size="large" />
<LoadingSpinner />
{:else if error}
<Icon icon={mdiAlertCircleOutline} size="24" class="text-red-600" />
{:else}
@@ -1,6 +1,8 @@
<script lang="ts">
import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/state';
import { intersectionObserver } from '$lib/actions/intersection-observer';
import { resizeObserver } from '$lib/actions/resize-observer';
import { shortcuts } from '$lib/actions/shortcut';
import MemoryPhotoViewer from '$lib/components/memory-page/memory-photo-viewer.svelte';
import MemoryVideoViewer from '$lib/components/memory-page/memory-video-viewer.svelte';
@@ -53,7 +55,6 @@
import type { NavigationTarget, Page } from '@sveltejs/kit';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
import type { Attachment } from 'svelte/attachments';
import { Tween } from 'svelte/motion';
let memoryGallery: HTMLElement | undefined = $state();
@@ -235,22 +236,6 @@
galleryFirstLoad = false;
};
const galleryObserver: Attachment<HTMLElement> = (element) => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
handleGalleryScrollsIntoView();
} else {
handleGalleryScrollsOutOfView();
}
},
{ rootMargin: '0px 0px -200px 0px' },
);
observer.observe(element);
return () => observer.disconnect();
};
const loadFromParams = (page: Page | NavigationTarget | null) => {
const assetId = page?.params?.assetId ?? page?.url.searchParams.get(QueryParameter.ID) ?? undefined;
return memoryStore.getMemoryAsset(assetId);
@@ -377,8 +362,7 @@
id="memory-viewer"
class="w-full bg-immich-dark-gray"
bind:this={memoryWrapper}
bind:clientHeight={viewport.height}
bind:clientWidth={viewport.width}
use:resizeObserver={({ height, width }) => ((viewport.height = height), (viewport.width = width))}
>
{#if current}
<ControlAppBar onClose={() => goto(Route.photos())} forceDark multiRow>
@@ -660,7 +644,15 @@
/>
</div>
<div id="gallery-memory" {@attach galleryObserver} bind:this={memoryGallery}>
<div
id="gallery-memory"
use:intersectionObserver={{
onIntersect: handleGalleryScrollsIntoView,
onSeparate: handleGalleryScrollsOutOfView,
bottom: '-200px',
}}
bind:this={memoryGallery}
>
<GalleryViewer
assets={currentTimelineAssets}
{viewerAssets}
@@ -0,0 +1,184 @@
<script lang="ts">
import appleSplash20482732 from '$lib/assets/apple/apple-splash-2048-2732.png';
import appleSplash27322048 from '$lib/assets/apple/apple-splash-2732-2048.png';
import appleSplash16682388 from '$lib/assets/apple/apple-splash-1668-2388.png';
import appleSplash23881668 from '$lib/assets/apple/apple-splash-2388-1668.png';
import appleSplash15362048 from '$lib/assets/apple/apple-splash-1536-2048.png';
import appleSplash20481536 from '$lib/assets/apple/apple-splash-2048-1536.png';
import appleSplash16682224 from '$lib/assets/apple/apple-splash-1668-2224.png';
import appleSplash22241668 from '$lib/assets/apple/apple-splash-2224-1668.png';
import appleSplash16202160 from '$lib/assets/apple/apple-splash-1620-2160.png';
import appleSplash21601620 from '$lib/assets/apple/apple-splash-2160-1620.png';
import appleSplash12902796 from '$lib/assets/apple/apple-splash-1290-2796.png';
import appleSplash27961290 from '$lib/assets/apple/apple-splash-2796-1290.png';
import appleSplash11792556 from '$lib/assets/apple/apple-splash-1179-2556.png';
import appleSplash25561179 from '$lib/assets/apple/apple-splash-2556-1179.png';
import appleSplash12842778 from '$lib/assets/apple/apple-splash-1284-2778.png';
import appleSplash27781284 from '$lib/assets/apple/apple-splash-2778-1284.png';
import appleSplash11702532 from '$lib/assets/apple/apple-splash-1170-2532.png';
import appleSplash25321170 from '$lib/assets/apple/apple-splash-2532-1170.png';
import appleSplash11252436 from '$lib/assets/apple/apple-splash-1125-2436.png';
import appleSplash24361125 from '$lib/assets/apple/apple-splash-2436-1125.png';
import appleSplash12422688 from '$lib/assets/apple/apple-splash-1242-2688.png';
import appleSplash26881242 from '$lib/assets/apple/apple-splash-2688-1242.png';
import appleSplash8281792 from '$lib/assets/apple/apple-splash-828-1792.png';
import appleSplash1792828 from '$lib/assets/apple/apple-splash-1792-828.png';
import appleSplash12422208 from '$lib/assets/apple/apple-splash-1242-2208.png';
import appleSplash22081242 from '$lib/assets/apple/apple-splash-2208-1242.png';
import appleSplash7501334 from '$lib/assets/apple/apple-splash-750-1334.png';
import appleSplash1334750 from '$lib/assets/apple/apple-splash-1334-750.png';
import appleSplash6401136 from '$lib/assets/apple/apple-splash-640-1136.png';
import appleSplash1136640 from '$lib/assets/apple/apple-splash-1136-640.png';
</script>
<link
rel="apple-touch-startup-image"
href={appleSplash20482732}
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash27322048}
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash16682388}
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash23881668}
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash15362048}
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash20481536}
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash16682224}
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash22241668}
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash16202160}
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash21601620}
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash12902796}
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash27961290}
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash11792556}
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash25561179}
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash12842778}
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash27781284}
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash11702532}
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash25321170}
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash11252436}
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash24361125}
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash12422688}
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash26881242}
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash8281792}
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash1792828}
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash12422208}
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash22081242}
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash7501334}
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash1334750}
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash6401136}
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash1136640}
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
@@ -57,6 +57,7 @@
return assetInteraction.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate);
});
const isAllUserOwned = $derived($user && selectedAssets.every((asset) => asset.ownerId === $user.id));
const handleLink: OnLink = ({ still, motion }) => {
timelineManager.removeAssets([motion.id]);
@@ -132,7 +133,7 @@
<SelectAllAssets {timelineManager} {assetInteraction} />
<ActionButton action={Actions.AddToAlbum} />
{#if assetInteraction.isAllUserOwned}
{#if isAllUserOwned}
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
@@ -50,7 +50,10 @@
<svelte:window bind:innerWidth />
<nav id="dashboard-navbar" class="max-md:h-(--navbar-height-md) h-(--navbar-height) w-dvw text-sm">
<nav
id="dashboard-navbar"
class="max-md:h-(--navbar-height-md) h-(--navbar-height) w-dvw text-sm bg-red-50 dark:bg-red-950"
>
<SkipLink text={$t('skip_to_content')} />
<div
class="grid h-full grid-cols-[--spacing(32)_auto] items-center py-2 sidebar:grid-cols-[--spacing(64)_auto] {noBorder
@@ -80,6 +83,7 @@
<a data-sveltekit-preload-data="hover" href={Route.photos()}>
<Logo variant={mediaQueryManager.isFullSidebar ? 'inline' : 'icon'} class="max-md:h-12" />
</a>
<span class="text-xs font-bold text-red-500 ms-2">[VISUAL TEST]</span>
</div>
<div class="flex justify-between gap-4 lg:gap-8 pe-6">
<div class="hidden w-full max-w-5xl flex-1 tall:ps-0 sm:block">
@@ -47,7 +47,7 @@
data-asset-id={asset.id}
class="absolute"
style:top={position.top + 'px'}
style:inset-inline-start={position.left + 'px'}
style:left={position.left + 'px'}
style:width={position.width + 'px'}
style:height={position.height + 'px'}
out:scale|global={{ start: 0.1, duration: scaleDuration }}
+2 -2
View File
@@ -54,6 +54,7 @@
</script>
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
{@const absoluteWidth = dayGroup.left}
{@const isDayGroupSelected = assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<section
@@ -63,8 +64,7 @@
]}
data-group
style:position="absolute"
style:inset-inline-start={dayGroup.start + 'px'}
style:top={dayGroup.top + 'px'}
style:transform={`translate3d(${absoluteWidth}px,${dayGroup.top}px,0)`}
onmouseenter={() => (hoveredDayGroup = dayGroup.groupTitle)}
onmouseleave={() => (hoveredDayGroup = null)}
>
@@ -1,6 +1,7 @@
<script lang="ts">
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { page } from '$app/state';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import Month from '$lib/components/timeline/Month.svelte';
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
@@ -259,6 +260,8 @@
const updateIsScrolling = () => (timelineManager.scrolling = true);
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
onMount(() => {
if (!enableRouting) {
invisible = false;
@@ -617,7 +620,7 @@
<section
id="asset-grid"
class={['scrollbar-hidden h-full overflow-y-auto outline-none', { 'm-0': isEmpty }, { 'ms-0': !isEmpty }]}
style:margin-inline-end={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
style:margin-right={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
tabindex="-1"
bind:clientHeight={timelineManager.viewportHeight}
bind:clientWidth={timelineManager.viewportWidth}
@@ -631,7 +634,7 @@
style:height={timelineManager.totalViewerHeight + 'px'}
>
<section
bind:clientHeight={timelineManager.topSectionHeight}
use:resizeObserver={topSectionResizeObserver}
class:invisible
style:position="absolute"
style:left="0"
@@ -66,7 +66,7 @@
<SettingsLanguageSelector showSettingDescription />
<Field label={$t('use_browser_locale')} description={$t('use_browser_locale_description')}>
<Field label={$t('default_locale')} description={$t('default_locale_description')}>
<Switch checked={$locale == 'default'} onCheckedChange={handleToggleLocaleBrowser} />
<Text size="small" class="mt-2 font-mono text-sm">{selectedDate}</Text>
</Field>
-99
View File
@@ -1,99 +0,0 @@
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { AssetMediaSize } from '@immich/sdk';
import { assetFactory } from '@test-data/factories/asset-factory';
vi.mock('$lib/utils/sw-messaging', () => ({
cancelImageUrl: vi.fn(),
}));
vi.mock('$lib/utils', () => ({
getAssetMediaUrl: vi.fn(),
}));
describe('ImageManager', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('preload', () => {
it('creates an Image with the correct URL', () => {
vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media');
const asset = assetFactory.build();
imageManager.preload(asset);
expect(getAssetMediaUrl).toHaveBeenCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
});
it('does nothing for undefined asset', () => {
imageManager.preload(undefined);
expect(getAssetMediaUrl).not.toHaveBeenCalled();
});
it('does nothing when getAssetMediaUrl returns falsy', () => {
vi.mocked(getAssetMediaUrl).mockReturnValue('');
const asset = assetFactory.build();
imageManager.preload(asset);
expect(getAssetMediaUrl).toHaveBeenCalled();
});
it('uses the specified size', () => {
vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media');
const asset = assetFactory.build();
imageManager.preload(asset, AssetMediaSize.Thumbnail);
expect(getAssetMediaUrl).toHaveBeenCalledWith({
id: asset.id,
size: AssetMediaSize.Thumbnail,
cacheKey: asset.thumbhash,
});
});
});
describe('cancel', () => {
it('calls cancelImageUrl with the correct URL', () => {
vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media');
const asset = assetFactory.build();
imageManager.cancel(asset, AssetMediaSize.Preview);
expect(cancelImageUrl).toHaveBeenCalledWith('/api/assets/123/media');
});
it('does nothing for undefined asset', () => {
imageManager.cancel(undefined);
expect(getAssetMediaUrl).not.toHaveBeenCalled();
expect(cancelImageUrl).not.toHaveBeenCalled();
});
it('cancels all sizes when size is "all"', () => {
vi.mocked(getAssetMediaUrl).mockImplementation(({ size }) => `/api/assets/123/${size}`);
const asset = assetFactory.build();
imageManager.cancel(asset, 'all');
expect(getAssetMediaUrl).toHaveBeenCalledTimes(Object.values(AssetMediaSize).length);
for (const size of Object.values(AssetMediaSize)) {
expect(cancelImageUrl).toHaveBeenCalledWith(`/api/assets/123/${size}`);
}
});
it('does not call cancelImageUrl when URL is falsy', () => {
vi.mocked(getAssetMediaUrl).mockReturnValue('');
const asset = assetFactory.build();
imageManager.cancel(asset, AssetMediaSize.Preview);
expect(cancelImageUrl).not.toHaveBeenCalled();
});
});
});

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