Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af8bf90dd9 | |||
| 20c639e52a | |||
| 6deb97d5bc | |||
| b282d83e95 | |||
| 5bc08f8654 | |||
| f54924d46a | |||
| dffe4d1d5c | |||
| 7f47cdd645 | |||
| 625b30c50a | |||
| 8619d14eca | |||
| 062546c168 | |||
| ea668d6b22 | |||
| f06af2c600 | |||
| 9dd2633e0c | |||
| 13a514c189 | |||
| b0c9120bb6 | |||
| bc4265416d | |||
| d4434f2276 |
@@ -1,406 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,6 @@ open-api/typescript-sdk/build
|
|||||||
mobile/android/fastlane/report.xml
|
mobile/android/fastlane/report.xml
|
||||||
mobile/ios/fastlane/report.xml
|
mobile/ios/fastlane/report.xml
|
||||||
|
|
||||||
screenshots-output
|
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
.devcontainer/library
|
.devcontainer/library
|
||||||
|
|||||||
@@ -18,10 +18,7 @@
|
|||||||
"format:fix": "prettier --write .",
|
"format:fix": "prettier --write .",
|
||||||
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
||||||
"lint:fix": "pnpm run lint --fix",
|
"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": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -54,7 +51,6 @@
|
|||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"socket.io-client": "^4.7.4",
|
"socket.io-client": "^4.7.4",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"tsx": "^4.21.0",
|
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"typescript-eslint": "^8.28.0",
|
"typescript-eslint": "^8.28.0",
|
||||||
"utimes": "^5.2.1",
|
"utimes": "^5.2.1",
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
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 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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}`);
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -10,27 +10,6 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
|
|||||||
path: '/',
|
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) => {
|
await context.route('**/api/users/me', async (route) => {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
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,6 +12,7 @@ import {
|
|||||||
TimelineData,
|
TimelineData,
|
||||||
} from 'src/ui/generators/timeline';
|
} from 'src/ui/generators/timeline';
|
||||||
import { sleep } from 'src/ui/specs/timeline/utils';
|
import { sleep } from 'src/ui/specs/timeline/utils';
|
||||||
|
import { MINIMAL_MP4_BUFFER } from './face-editor-network';
|
||||||
|
|
||||||
export class TimelineTestContext {
|
export class TimelineTestContext {
|
||||||
slowBucket = false;
|
slowBucket = false;
|
||||||
@@ -135,6 +136,14 @@ export const setupTimelineMockApiRoutes = async (
|
|||||||
return route.continue();
|
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) => {
|
await context.route('**/api/albums/**', async (route, request) => {
|
||||||
const albumsMatch = request.url().match(/\/api\/albums\/(?<albumId>[^/?]+)/);
|
const albumsMatch = request.url().match(/\/api\/albums\/(?<albumId>[^/?]+)/);
|
||||||
if (albumsMatch) {
|
if (albumsMatch) {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
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 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import android.database.Cursor
|
|||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.ext.SdkExtensions
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -78,15 +79,22 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
add(MediaStore.MediaColumns.IS_FAVORITE)
|
add(MediaStore.MediaColumns.IS_FAVORITE)
|
||||||
}
|
}
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
if (hasSpecialFormatColumn()) {
|
||||||
add(SPECIAL_FORMAT_COLUMN)
|
add(SPECIAL_FORMAT_COLUMN)
|
||||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
// Fallback: read XMP from MediaStore to detect Motion Photos
|
// Fallback: read XMP from MediaStore to detect Motion Photos
|
||||||
|
// only needed if SPECIAL_FORMAT column isn't available
|
||||||
add(MediaStore.MediaColumns.XMP)
|
add(MediaStore.MediaColumns.XMP)
|
||||||
}
|
}
|
||||||
}.toTypedArray()
|
}.toTypedArray()
|
||||||
|
|
||||||
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
|
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(
|
protected fun getCursor(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
@@ -11,7 +12,8 @@ import 'package:immich_mobile/domain/models/store.model.dart';
|
|||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
||||||
import 'package:immich_mobile/generated/translations.g.dart';
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:path/path.dart' as path;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||||
@@ -129,11 +131,16 @@ class _BottomPanelState extends State<_BottomPanel> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final db = Drift();
|
|
||||||
try {
|
try {
|
||||||
await db.reset();
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
} finally {
|
for (final suffix in ['', '-wal', '-shm']) {
|
||||||
await db.close();
|
final file = File(path.join(dir.path, 'immich.sqlite$suffix'));
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|||||||
@@ -14,15 +14,15 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.wi
|
|||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.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/asset_viewer.state.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.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/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.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.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/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/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/common/immich_loading_indicator.dart';
|
||||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||||
|
|
||||||
@@ -53,7 +53,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
|
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController);
|
late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController);
|
||||||
final ValueNotifier<PhotoViewScaleState> _videoScaleStateNotifier = ValueNotifier(PhotoViewScaleState.initial);
|
|
||||||
|
|
||||||
double _snapOffset = 0.0;
|
double _snapOffset = 0.0;
|
||||||
|
|
||||||
@@ -79,7 +78,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
_proxyScrollController.dispose();
|
_proxyScrollController.dispose();
|
||||||
_scaleBoundarySub?.cancel();
|
_scaleBoundarySub?.cancel();
|
||||||
_eventSubscription?.cancel();
|
_eventSubscription?.cancel();
|
||||||
_videoScaleStateNotifier.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,17 +247,14 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
|
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
|
||||||
|
|
||||||
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
|
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
|
||||||
_isZoomed =
|
_isZoomed = switch (scaleState) {
|
||||||
scaleState == PhotoViewScaleState.zoomedIn ||
|
PhotoViewScaleState.zoomedIn || PhotoViewScaleState.covering => true,
|
||||||
scaleState == PhotoViewScaleState.covering ||
|
_ => false,
|
||||||
_videoScaleStateNotifier.value == PhotoViewScaleState.zoomedIn ||
|
};
|
||||||
_videoScaleStateNotifier.value == PhotoViewScaleState.covering;
|
|
||||||
_viewer.setZoomed(_isZoomed);
|
_viewer.setZoomed(_isZoomed);
|
||||||
|
|
||||||
if (scaleState != PhotoViewScaleState.initial) {
|
if (scaleState != PhotoViewScaleState.initial) {
|
||||||
if (_dragStart == null) _viewer.setControls(false);
|
if (_dragStart == null) _viewer.setControls(false);
|
||||||
|
|
||||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,35 +329,40 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return PhotoView.customChild(
|
return Stack(
|
||||||
key: Key(displayAsset.heroTag),
|
children: [
|
||||||
onDragStart: _onDragStart,
|
PhotoView.customChild(
|
||||||
onDragUpdate: _onDragUpdate,
|
key: Key(displayAsset.heroTag),
|
||||||
onDragEnd: _onDragEnd,
|
onDragStart: _onDragStart,
|
||||||
onDragCancel: _onDragCancel,
|
onDragUpdate: _onDragUpdate,
|
||||||
heroAttributes: heroAttributes,
|
onDragEnd: _onDragEnd,
|
||||||
filterQuality: FilterQuality.high,
|
onDragCancel: _onDragCancel,
|
||||||
basePosition: Alignment.center,
|
onTapUp: _onTapUp,
|
||||||
disableScaleGestures: true,
|
heroAttributes: heroAttributes,
|
||||||
minScale: PhotoViewComputedScale.contained,
|
basePosition: Alignment.center,
|
||||||
initialScale: PhotoViewComputedScale.contained,
|
disableScaleGestures: showingDetails,
|
||||||
tightMode: true,
|
scaleStateChangedCallback: _onScaleStateChanged,
|
||||||
onPageBuild: _onPageBuild,
|
onPageBuild: _onPageBuild,
|
||||||
enablePanAlways: true,
|
enablePanAlways: true,
|
||||||
backgroundDecoration: backgroundDecoration,
|
backgroundDecoration: backgroundDecoration,
|
||||||
child: NativeVideoViewer(
|
child: SizedBox(
|
||||||
key: _NativeVideoViewerKey(displayAsset.heroTag),
|
width: context.width,
|
||||||
asset: displayAsset,
|
height: context.height,
|
||||||
scaleStateNotifier: _videoScaleStateNotifier,
|
child: NativeVideoViewer(
|
||||||
disableScaleGestures: showingDetails,
|
key: _NativeVideoViewerKey(displayAsset.heroTag),
|
||||||
image: Image(
|
asset: displayAsset,
|
||||||
image: getFullImageProvider(displayAsset, size: context.sizeData),
|
image: Image(
|
||||||
height: context.height,
|
image: getFullImageProvider(displayAsset, size: context.sizeData),
|
||||||
width: context.width,
|
fit: BoxFit.contain,
|
||||||
fit: BoxFit.contain,
|
height: context.height,
|
||||||
alignment: Alignment.center,
|
width: context.width,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const Center(child: VideoViewerControls()),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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_preloader.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.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/asset_viewer.state.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/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/providers/asset_viewer/video_player_controls_provider.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/asset_viewer/video_player_value_provider.dart';
|
||||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
|
|||||||
@@ -9,11 +9,9 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
|
|||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/setting.service.dart';
|
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.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/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.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/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.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/providers/asset_viewer/video_player_controls_provider.dart';
|
||||||
@@ -26,7 +24,6 @@ import 'package:immich_mobile/services/api.service.dart';
|
|||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/utils/debounce.dart';
|
import 'package:immich_mobile/utils/debounce.dart';
|
||||||
import 'package:immich_mobile/utils/hooks/interval_hook.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:logging/logging.dart';
|
||||||
import 'package:native_video_player/native_video_player.dart';
|
import 'package:native_video_player/native_video_player.dart';
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
@@ -51,21 +48,10 @@ bool _isCurrentAsset(BaseAsset asset, BaseAsset? currentAsset) {
|
|||||||
class NativeVideoViewer extends HookConsumerWidget {
|
class NativeVideoViewer extends HookConsumerWidget {
|
||||||
static final log = Logger('NativeVideoViewer');
|
static final log = Logger('NativeVideoViewer');
|
||||||
final BaseAsset asset;
|
final BaseAsset asset;
|
||||||
final bool showControls;
|
|
||||||
final int playbackDelayFactor;
|
final int playbackDelayFactor;
|
||||||
final Widget image;
|
final Widget image;
|
||||||
final ValueNotifier<PhotoViewScaleState>? scaleStateNotifier;
|
|
||||||
final bool disableScaleGestures;
|
|
||||||
|
|
||||||
const NativeVideoViewer({
|
const NativeVideoViewer({super.key, required this.asset, required this.image, this.playbackDelayFactor = 1});
|
||||||
super.key,
|
|
||||||
required this.asset,
|
|
||||||
required this.image,
|
|
||||||
this.showControls = true,
|
|
||||||
this.playbackDelayFactor = 1,
|
|
||||||
this.scaleStateNotifier,
|
|
||||||
this.disableScaleGestures = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -144,7 +130,6 @@ class NativeVideoViewer extends HookConsumerWidget {
|
|||||||
|
|
||||||
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
|
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
|
||||||
final aspectRatio = useState<double?>(null);
|
final aspectRatio = useState<double?>(null);
|
||||||
|
|
||||||
useMemoized(() async {
|
useMemoized(() async {
|
||||||
if (!context.mounted || aspectRatio.value != null) {
|
if (!context.mounted || aspectRatio.value != null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -320,20 +305,6 @@ class NativeVideoViewer extends HookConsumerWidget {
|
|||||||
Timer(const Duration(milliseconds: 200), checkIfBuffering);
|
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) {
|
ref.listen(currentAssetNotifier, (_, value) {
|
||||||
final playerController = controller.value;
|
final playerController = controller.value;
|
||||||
if (playerController != null && value != asset) {
|
if (playerController != null && value != asset) {
|
||||||
@@ -414,29 +385,22 @@ class NativeVideoViewer extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return SizedBox(
|
return Stack(
|
||||||
width: context.width,
|
children: [
|
||||||
height: context.height,
|
// This remains under the video to avoid flickering
|
||||||
child: Stack(
|
// For motion videos, this is the image portion of the asset
|
||||||
children: [
|
Center(child: image),
|
||||||
// Hide thumbnail once video is visible to avoid it showing in background when zooming out on video.
|
if (aspectRatio.value != null && !isCasting)
|
||||||
if (!isVisible.value || controller.value == null) Center(child: image),
|
Visibility.maintain(
|
||||||
if (aspectRatio.value != null && !isCasting && isCurrent)
|
visible: isVisible.value,
|
||||||
Visibility.maintain(
|
child: Center(
|
||||||
visible: isVisible.value,
|
child: AspectRatio(
|
||||||
child: PhotoView.customChild(
|
aspectRatio: aspectRatio.value!,
|
||||||
enableRotation: false,
|
child: isCurrent ? NativeVideoPlayerView(onViewReady: initController) : null,
|
||||||
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,7 +58,6 @@ class DriftMemoryCard extends StatelessWidget {
|
|||||||
child: NativeVideoViewer(
|
child: NativeVideoViewer(
|
||||||
key: ValueKey(asset.id),
|
key: ValueKey(asset.id),
|
||||||
asset: asset,
|
asset: asset,
|
||||||
showControls: false,
|
|
||||||
playbackDelayFactor: 2,
|
playbackDelayFactor: 2,
|
||||||
image: FullImage(asset, size: Size(context.width, context.height), fit: BoxFit.contain),
|
image: FullImage(asset, size: Size(context.width, context.height), fit: BoxFit.contain),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -276,9 +276,6 @@ importers:
|
|||||||
supertest:
|
supertest:
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.2.2
|
version: 7.2.2
|
||||||
tsx:
|
|
||||||
specifier: ^4.21.0
|
|
||||||
version: 4.21.0
|
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.3.3
|
specifier: ^5.3.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import { createZoomImageWheel } from '@zoom-image/core';
|
import { createZoomImageWheel } from '@zoom-image/core';
|
||||||
|
|
||||||
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
|
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean; zoomTarget?: HTMLElement }) => {
|
||||||
const zoomInstance = createZoomImageWheel(node, { maxZoom: 10, initialState: assetViewerManager.zoomState });
|
const zoomInstance = createZoomImageWheel(node, {
|
||||||
|
maxZoom: 10,
|
||||||
|
initialState: assetViewerManager.zoomState,
|
||||||
|
zoomTarget: options?.zoomTarget,
|
||||||
|
});
|
||||||
|
|
||||||
const unsubscribes = [
|
const unsubscribes = [
|
||||||
assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }),
|
assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }),
|
||||||
@@ -20,8 +24,11 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
|
|||||||
|
|
||||||
node.style.overflow = 'visible';
|
node.style.overflow = 'visible';
|
||||||
return {
|
return {
|
||||||
update(newOptions?: { disabled?: boolean }) {
|
update(newOptions?: { disabled?: boolean; zoomTarget?: HTMLElement }) {
|
||||||
options = newOptions;
|
options = newOptions;
|
||||||
|
if (newOptions?.zoomTarget !== undefined) {
|
||||||
|
zoomInstance.setState({ zoomTarget: newOptions.zoomTarget });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
for (const unsubscribe of unsubscribes) {
|
for (const unsubscribe of unsubscribes) {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 242 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 311 KiB |
|
Before Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 98 KiB |
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||||
import { onDestroy, untrack } from 'svelte';
|
import { onDestroy, untrack } from 'svelte';
|
||||||
import type { HTMLImgAttributes } from 'svelte/elements';
|
import type { HTMLImgAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
if (capturedSource !== undefined) {
|
if (capturedSource !== undefined) {
|
||||||
imageManager.cancelPreloadUrl(capturedSource);
|
cancelImageUrl(capturedSource);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
import { SettingInputFieldType } from '$lib/constants';
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||||
import { getQueueName } from '$lib/utils';
|
|
||||||
import { QueueName, type SystemConfigJobDto } from '@immich/sdk';
|
import { QueueName, type SystemConfigJobDto } from '@immich/sdk';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
@@ -30,6 +29,27 @@
|
|||||||
function isSystemConfigJobDto(jobName: string): jobName is keyof SystemConfigJobDto {
|
function isSystemConfigJobDto(jobName: string): jobName is keyof SystemConfigJobDto {
|
||||||
return jobName in configToEdit.job;
|
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>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -41,7 +61,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
{disabled}
|
{disabled}
|
||||||
label={$t('admin.job_concurrency', { values: { job: $getQueueName(queueName) } })}
|
label={$t('admin.job_concurrency', { values: { job: queueTitles[queueName] } })}
|
||||||
description=""
|
description=""
|
||||||
bind:value={configToEdit.job[queueName].concurrency}
|
bind:value={configToEdit.job[queueName].concurrency}
|
||||||
required={true}
|
required={true}
|
||||||
@@ -50,7 +70,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
label={$t('admin.job_concurrency', { values: { job: $getQueueName(queueName) } })}
|
label={$t('admin.job_concurrency', { values: { job: queueTitles[queueName] } })}
|
||||||
description=""
|
description=""
|
||||||
value={1}
|
value={1}
|
||||||
disabled={true}
|
disabled={true}
|
||||||
|
|||||||
@@ -11,85 +11,38 @@
|
|||||||
|
|
||||||
let { options }: Props = $props();
|
let { options }: Props = $props();
|
||||||
|
|
||||||
const getLuxonExample = (format: string) => {
|
const getExampleDate = () => DateTime.fromISO('2022-02-15T20:03:05.250Z', { locale: $locale });
|
||||||
return DateTime.fromISO('2022-02-15T20:03:05.250Z', { locale: $locale }).toFormat(format);
|
|
||||||
};
|
|
||||||
</script>
|
</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>
|
<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">
|
<Card class="mt-2 text-sm bg-light-50 shadow-none">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Text class="mb-1">{$t('admin.storage_template_date_time_description')}</Text>
|
<Text class="mb-1">{$t('admin.storage_template_date_time_description')}</Text>
|
||||||
<Text color="primary"
|
<Text color="primary"
|
||||||
>{$t('admin.storage_template_date_time_sample', { values: { date: '2022-02-15T20:03:05.250+00:00' } })}</Text
|
>{$t('admin.storage_template_date_time_sample', { values: { date: getExampleDate().toISO() } })}</Text
|
||||||
>
|
>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-3 md:grid-cols-4">
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-3 md:grid-cols-4">
|
||||||
<div>
|
{@render example($t('year'), options.yearOptions)}
|
||||||
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('year')}</Text>
|
{@render example($t('month'), options.monthOptions)}
|
||||||
<ul>
|
{@render example($t('week'), options.weekOptions)}
|
||||||
{#each options.yearOptions as yearFormat, index (index)}
|
{@render example($t('day'), options.dayOptions)}
|
||||||
<li>{'{{'}{yearFormat}{'}}'} - {getLuxonExample(yearFormat)}</li>
|
{@render example($t('hour'), options.hourOptions)}
|
||||||
{/each}
|
{@render example($t('minute'), options.minuteOptions)}
|
||||||
</ul>
|
{@render example($t('second'), options.secondOptions)}
|
||||||
</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>
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||||
|
import { getContentMetrics, getNaturalSize } from '$lib/utils/container-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||||
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
||||||
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
|
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
|
||||||
|
import { clamp } from 'lodash-es';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
@@ -23,10 +25,12 @@
|
|||||||
let canvas: Canvas | undefined = $state();
|
let canvas: Canvas | undefined = $state();
|
||||||
let faceRect: Rect | undefined = $state();
|
let faceRect: Rect | undefined = $state();
|
||||||
let faceSelectorEl: HTMLDivElement | undefined = $state();
|
let faceSelectorEl: HTMLDivElement | undefined = $state();
|
||||||
|
let scrollableListEl: HTMLDivElement | undefined = $state();
|
||||||
let page = $state(1);
|
let page = $state(1);
|
||||||
let candidates = $state<PersonResponseDto[]>([]);
|
let candidates = $state<PersonResponseDto[]>([]);
|
||||||
|
|
||||||
let searchTerm = $state('');
|
let searchTerm = $state('');
|
||||||
|
let faceBoxPosition = $state({ left: 0, top: 0, width: 0, height: 0 });
|
||||||
|
|
||||||
let filteredCandidates = $derived(
|
let filteredCandidates = $derived(
|
||||||
searchTerm
|
searchTerm
|
||||||
@@ -78,17 +82,13 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const { actualWidth, actualHeight } = getContainedSize(htmlElement);
|
const metrics = getContentMetrics(htmlElement);
|
||||||
const offsetArea = {
|
|
||||||
width: (containerWidth - actualWidth) / 2,
|
|
||||||
height: (containerHeight - actualHeight) / 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const imageBoundingBox = {
|
const imageBoundingBox = {
|
||||||
top: offsetArea.height,
|
top: metrics.offsetY,
|
||||||
left: offsetArea.width,
|
left: metrics.offsetX,
|
||||||
width: containerWidth - offsetArea.width * 2,
|
width: metrics.contentWidth,
|
||||||
height: containerHeight - offsetArea.height * 2,
|
height: metrics.contentHeight,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
@@ -113,32 +113,6 @@
|
|||||||
positionFaceSelector();
|
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 = () => {
|
const cancel = () => {
|
||||||
isFaceEditMode.value = false;
|
isFaceEditMode.value = false;
|
||||||
};
|
};
|
||||||
@@ -157,69 +131,80 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MAX_LIST_HEIGHT = 250;
|
||||||
|
|
||||||
const positionFaceSelector = () => {
|
const positionFaceSelector = () => {
|
||||||
if (!faceRect || !faceSelectorEl) {
|
if (!faceRect || !faceSelectorEl || !scrollableListEl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rect = faceRect.getBoundingRect();
|
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 selectorWidth = faceSelectorEl.offsetWidth;
|
const selectorWidth = faceSelectorEl.offsetWidth;
|
||||||
const selectorHeight = faceSelectorEl.offsetHeight;
|
const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight;
|
||||||
|
const listHeight = Math.min(MAX_LIST_HEIGHT, containerHeight - gap * 2 - chromeHeight);
|
||||||
|
const selectorHeight = listHeight + chromeHeight;
|
||||||
|
|
||||||
const spaceAbove = rect.top;
|
const clampTop = (top: number) => clamp(top, gap, containerHeight - selectorHeight - gap);
|
||||||
const spaceBelow = containerHeight - (rect.top + rect.height);
|
const clampLeft = (left: number) => clamp(left, gap, containerWidth - selectorWidth - gap);
|
||||||
const spaceLeft = rect.left;
|
|
||||||
const spaceRight = containerWidth - (rect.left + rect.width);
|
|
||||||
|
|
||||||
let top, left;
|
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;
|
||||||
|
|
||||||
if (
|
const overlapX = Math.max(0, Math.min(selectorRight, faceRight) - Math.max(position.left, faceBox.left));
|
||||||
spaceBelow >= selectorHeight ||
|
const overlapY = Math.max(0, Math.min(selectorBottom, faceBottom) - Math.max(position.top, faceBox.top));
|
||||||
(spaceBelow >= spaceAbove && spaceBelow >= spaceLeft && spaceBelow >= spaceRight)
|
return overlapX * overlapY;
|
||||||
) {
|
};
|
||||||
top = rect.top + rect.height + 15;
|
|
||||||
left = rect.left;
|
const faceBottom = faceBox.top + faceBox.height;
|
||||||
} else if (
|
const faceRight = faceBox.left + faceBox.width;
|
||||||
spaceAbove >= selectorHeight ||
|
|
||||||
(spaceAbove >= spaceBelow && spaceAbove >= spaceLeft && spaceAbove >= spaceRight)
|
const positions = [
|
||||||
) {
|
{ top: clampTop(faceBottom + gap), left: clampLeft(faceBox.left) },
|
||||||
top = rect.top - selectorHeight - 15;
|
{ top: clampTop(faceBox.top - selectorHeight - gap), left: clampLeft(faceBox.left) },
|
||||||
left = rect.left;
|
{ top: clampTop(faceBox.top), left: clampLeft(faceRight + gap) },
|
||||||
} else if (
|
{ top: clampTop(faceBox.top), left: clampLeft(faceBox.left - selectorWidth - gap) },
|
||||||
spaceRight >= selectorWidth ||
|
];
|
||||||
(spaceRight >= spaceLeft && spaceRight >= spaceAbove && spaceRight >= spaceBelow)
|
|
||||||
) {
|
let bestPosition = positions[0];
|
||||||
top = rect.top;
|
let leastOverlap = Infinity;
|
||||||
left = rect.left + rect.width + 15;
|
|
||||||
} else {
|
for (const position of positions) {
|
||||||
top = rect.top;
|
const overlap = overlapArea(position);
|
||||||
left = rect.left - selectorWidth - 15;
|
if (overlap < leastOverlap) {
|
||||||
|
leastOverlap = overlap;
|
||||||
|
bestPosition = position;
|
||||||
|
if (overlap === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (left + selectorWidth > containerWidth) {
|
faceSelectorEl.style.top = `${bestPosition.top}px`;
|
||||||
left = containerWidth - selectorWidth - 15;
|
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 < 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(() => {
|
$effect(() => {
|
||||||
if (faceRect) {
|
const rect = faceRect;
|
||||||
faceRect.on('moving', positionFaceSelector);
|
if (rect) {
|
||||||
faceRect.on('scaling', positionFaceSelector);
|
rect.on('moving', positionFaceSelector);
|
||||||
|
rect.on('scaling', positionFaceSelector);
|
||||||
|
return () => {
|
||||||
|
rect.off('moving', positionFaceSelector);
|
||||||
|
rect.off('scaling', positionFaceSelector);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -229,48 +214,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { left, top, width, height } = faceRect.getBoundingRect();
|
const { left, top, width, height } = faceRect.getBoundingRect();
|
||||||
const { actualWidth, actualHeight } = getContainedSize(htmlElement);
|
const metrics = getContentMetrics(htmlElement);
|
||||||
|
const natural = getNaturalSize(htmlElement);
|
||||||
|
|
||||||
const offsetArea = {
|
const scaleX = natural.width / metrics.contentWidth;
|
||||||
width: (containerWidth - actualWidth) / 2,
|
const scaleY = natural.height / metrics.contentHeight;
|
||||||
height: (containerHeight - actualHeight) / 2,
|
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 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) => {
|
const tagFace = async (person: PersonResponseDto) => {
|
||||||
@@ -308,13 +267,20 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="absolute start-0 top-0">
|
<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}
|
||||||
|
>
|
||||||
<canvas bind:this={canvasEl} id="face-editor" class="absolute top-0 start-0"></canvas>
|
<canvas bind:this={canvasEl} id="face-editor" class="absolute top-0 start-0"></canvas>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
id="face-selector"
|
id="face-selector"
|
||||||
bind:this={faceSelectorEl}
|
bind:this={faceSelectorEl}
|
||||||
class="absolute top-[calc(50%-250px)] start-[calc(50%-125px)] max-w-[250px] w-[250px] bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800"
|
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"
|
||||||
>
|
>
|
||||||
<p class="text-center text-sm">{$t('select_person_to_tag')}</p>
|
<p class="text-center text-sm">{$t('select_person_to_tag')}</p>
|
||||||
|
|
||||||
@@ -322,7 +288,7 @@
|
|||||||
<Input placeholder={$t('search_people')} bind:value={searchTerm} size="tiny" />
|
<Input placeholder={$t('search_people')} bind:value={searchTerm} size="tiny" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="h-62.5 overflow-y-auto mt-2">
|
<div bind:this={scrollableListEl} class="h-62.5 overflow-y-auto mt-2">
|
||||||
{#if filteredCandidates.length > 0}
|
{#if filteredCandidates.length > 0}
|
||||||
<div class="mt-2 rounded-lg">
|
<div class="mt-2 rounded-lg">
|
||||||
{#each filteredCandidates as person (person.id)}
|
{#each filteredCandidates as person (person.id)}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
||||||
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
||||||
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
||||||
import { calculateBoundingBoxMatrix, getOcrBoundingBoxesAtSize, type Point } from '$lib/utils/ocr-utils';
|
import { calculateBoundingBoxMatrix, getOcrBoundingBoxes, type Point } from '$lib/utils/ocr-utils';
|
||||||
import {
|
import {
|
||||||
EquirectangularAdapter,
|
EquirectangularAdapter,
|
||||||
Viewer,
|
Viewer,
|
||||||
@@ -127,9 +127,11 @@
|
|||||||
markersPlugin.clearMarkers();
|
markersPlugin.clearMarkers();
|
||||||
}
|
}
|
||||||
|
|
||||||
const boxes = getOcrBoundingBoxesAtSize(ocrData, {
|
const boxes = getOcrBoundingBoxes(ocrData, {
|
||||||
width: viewer.state.textureData.panoData.croppedWidth,
|
contentWidth: viewer.state.textureData.panoData.croppedWidth,
|
||||||
height: viewer.state.textureData.panoData.croppedHeight,
|
contentHeight: viewer.state.textureData.panoData.croppedHeight,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const [index, box] of boxes.entries()) {
|
for (const [index, box] of boxes.entries()) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
||||||
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
|
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
|
||||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-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 { handleError } from '$lib/utils/handle-error';
|
||||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
let imageLoaded: boolean = $state(false);
|
let imageLoaded: boolean = $state(false);
|
||||||
let originalImageLoaded: boolean = $state(false);
|
let originalImageLoaded: boolean = $state(false);
|
||||||
let imageError: boolean = $state(false);
|
let imageError: boolean = $state(false);
|
||||||
|
let visibleImageReady: boolean = $state(false);
|
||||||
|
|
||||||
let loader = $state<HTMLImageElement>();
|
let loader = $state<HTMLImageElement>();
|
||||||
|
|
||||||
@@ -67,11 +69,23 @@
|
|||||||
$boundingBoxesArray = [];
|
$boundingBoxesArray = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
let ocrBoxes = $derived(
|
const overlayMetrics = $derived.by((): ContentMetrics => {
|
||||||
ocrManager.showOverlay && assetViewerManager.imgRef
|
if (!assetViewerManager.imgRef || !visibleImageReady) {
|
||||||
? getOcrBoundingBoxes(ocrManager.data, assetViewerManager.zoomState, assetViewerManager.imgRef)
|
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 isOcrActive = $derived(ocrManager.showOverlay);
|
let isOcrActive = $derived(ocrManager.showOverlay);
|
||||||
|
|
||||||
@@ -159,7 +173,7 @@
|
|||||||
imageError = imageLoaded = true;
|
imageError = imageLoaded = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
onDestroy(() => imageManager.cancelPreloadUrl(imageLoaderUrl));
|
onDestroy(() => imageManager.cancel(asset, targetImageSize));
|
||||||
|
|
||||||
let imageLoaderUrl = $derived(
|
let imageLoaderUrl = $derived(
|
||||||
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || assetViewerManager.zoom > 1 }),
|
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || assetViewerManager.zoom > 1 }),
|
||||||
@@ -176,6 +190,7 @@
|
|||||||
imageLoaded = false;
|
imageLoaded = false;
|
||||||
originalImageLoaded = false;
|
originalImageLoaded = false;
|
||||||
imageError = false;
|
imageError = false;
|
||||||
|
visibleImageReady = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
lastUrl = imageLoaderUrl;
|
lastUrl = imageLoaderUrl;
|
||||||
@@ -226,6 +241,7 @@
|
|||||||
<img
|
<img
|
||||||
bind:this={assetViewerManager.imgRef}
|
bind:this={assetViewerManager.imgRef}
|
||||||
src={imageLoaderUrl}
|
src={imageLoaderUrl}
|
||||||
|
onload={() => (visibleImageReady = true)}
|
||||||
alt={$getAltText(toTimelineAsset(asset))}
|
alt={$getAltText(toTimelineAsset(asset))}
|
||||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
class="h-full w-full {$slideshowState === SlideshowState.None
|
||||||
? 'object-contain'
|
? 'object-contain'
|
||||||
@@ -233,7 +249,7 @@
|
|||||||
draggable="false"
|
draggable="false"
|
||||||
/>
|
/>
|
||||||
<!-- eslint-disable-next-line svelte/require-each-key -->
|
<!-- eslint-disable-next-line svelte/require-each-key -->
|
||||||
{#each getBoundingBox($boundingBoxesArray, assetViewerManager.zoomState, assetViewerManager.imgRef) as boundingbox}
|
{#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox}
|
||||||
<div
|
<div
|
||||||
class="absolute border-solid border-white border-3 rounded-lg"
|
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;"
|
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||||
|
|||||||
@@ -15,15 +15,18 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
data-broken-asset
|
||||||
class={[
|
class={[
|
||||||
'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',
|
'@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',
|
||||||
className,
|
className,
|
||||||
]}
|
]}
|
||||||
style:width
|
style:width
|
||||||
style:height
|
style:height
|
||||||
>
|
>
|
||||||
<Icon icon={mdiImageBrokenVariant} size="7em" class="max-w-full" />
|
<div class="hidden @min-[75px]:block">
|
||||||
|
<Icon icon={mdiImageBrokenVariant} size="7em" class="max-w-full min-w-6 min-h-6" />
|
||||||
|
</div>
|
||||||
{#if !hideMessage}
|
{#if !hideMessage}
|
||||||
<span class="text-center">{$t('error_loading_image')}</span>
|
<span class="text-center text-xs @min-[100px]:text-sm @min-[150px]:text-base">{$t('error_loading_image')}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -283,8 +283,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver}
|
{:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver}
|
||||||
<!-- GIF -->
|
<!-- GIF -->
|
||||||
<div class="absolute top-0 h-full w-full pointer-events-none">
|
<div class="absolute 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
|
<ImageThumbnail
|
||||||
class={imageClass}
|
class={imageClass}
|
||||||
{brokenAssetClass}
|
{brokenAssetClass}
|
||||||
@@ -294,11 +293,6 @@
|
|||||||
heightStyle="{height}px"
|
heightStyle="{height}px"
|
||||||
curve={selected}
|
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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -318,7 +312,7 @@
|
|||||||
<!-- icon overlay -->
|
<!-- icon overlay -->
|
||||||
<div class="z-2 absolute inset-0">
|
<div class="z-2 absolute inset-0">
|
||||||
<!-- Gradient overlay on hover -->
|
<!-- Gradient overlay on hover -->
|
||||||
{#if !usingMobileDevice && !disabled}
|
{#if !usingMobileDevice && !disabled && !asset.isVideo}
|
||||||
<div
|
<div
|
||||||
class={[
|
class={[
|
||||||
'absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100 ',
|
'absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100 ',
|
||||||
@@ -354,7 +348,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
|
{#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" />
|
<Icon data-icon-archive icon={mdiArchiveArrowDownOutline} size="24" class="text-white" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -362,7 +356,7 @@
|
|||||||
{#if asset.isImage && asset.projectionType === ProjectionType.EQUIRECTANGULAR}
|
{#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">
|
<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">
|
<span class="pe-2 pt-2">
|
||||||
<Icon data-icon-equirectangular icon={mdiRotate360} size="24" />
|
<Icon icon={mdiRotate360} size="24" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -370,7 +364,7 @@
|
|||||||
{#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')}
|
{#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">
|
<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">
|
<span class="pe-2 pt-2">
|
||||||
<Icon data-icon-playable icon={mdiFileGifBox} size="24" />
|
<Icon icon={mouseOver ? mdiMotionPauseOutline : mdiFileGifBox} size="24" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -385,7 +379,7 @@
|
|||||||
>
|
>
|
||||||
<span class="pe-2 pt-2 flex place-items-center gap-1">
|
<span class="pe-2 pt-2 flex place-items-center gap-1">
|
||||||
<p>{asset.stack.assetCount.toLocaleString($locale)}</p>
|
<p>{asset.stack.assetCount.toLocaleString($locale)}</p>
|
||||||
<Icon data-icon-stack icon={mdiCameraBurst} size="24" />
|
<Icon icon={mdiCameraBurst} size="24" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@
|
|||||||
<span class="pe-2 pt-2 drop-shadow-[1px_1px_6px_rgb(0_0_0)]" onmouseenter={onMouseEnter} onmouseleave={onMouseLeave}>
|
<span class="pe-2 pt-2 drop-shadow-[1px_1px_6px_rgb(0_0_0)]" onmouseenter={onMouseEnter} onmouseleave={onMouseLeave}>
|
||||||
{#if enablePlayback}
|
{#if enablePlayback}
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<LoadingSpinner />
|
<LoadingSpinner size="large" />
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<Icon icon={mdiAlertCircleOutline} size="24" class="text-red-600" />
|
<Icon icon={mdiAlertCircleOutline} size="24" class="text-red-600" />
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { afterNavigate, goto } from '$app/navigation';
|
import { afterNavigate, goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
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 { shortcuts } from '$lib/actions/shortcut';
|
||||||
import MemoryPhotoViewer from '$lib/components/memory-page/memory-photo-viewer.svelte';
|
import MemoryPhotoViewer from '$lib/components/memory-page/memory-photo-viewer.svelte';
|
||||||
import MemoryVideoViewer from '$lib/components/memory-page/memory-video-viewer.svelte';
|
import MemoryVideoViewer from '$lib/components/memory-page/memory-video-viewer.svelte';
|
||||||
@@ -55,6 +53,7 @@
|
|||||||
import type { NavigationTarget, Page } from '@sveltejs/kit';
|
import type { NavigationTarget, Page } from '@sveltejs/kit';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import type { Attachment } from 'svelte/attachments';
|
||||||
import { Tween } from 'svelte/motion';
|
import { Tween } from 'svelte/motion';
|
||||||
|
|
||||||
let memoryGallery: HTMLElement | undefined = $state();
|
let memoryGallery: HTMLElement | undefined = $state();
|
||||||
@@ -236,6 +235,22 @@
|
|||||||
galleryFirstLoad = false;
|
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 loadFromParams = (page: Page | NavigationTarget | null) => {
|
||||||
const assetId = page?.params?.assetId ?? page?.url.searchParams.get(QueryParameter.ID) ?? undefined;
|
const assetId = page?.params?.assetId ?? page?.url.searchParams.get(QueryParameter.ID) ?? undefined;
|
||||||
return memoryStore.getMemoryAsset(assetId);
|
return memoryStore.getMemoryAsset(assetId);
|
||||||
@@ -362,7 +377,8 @@
|
|||||||
id="memory-viewer"
|
id="memory-viewer"
|
||||||
class="w-full bg-immich-dark-gray"
|
class="w-full bg-immich-dark-gray"
|
||||||
bind:this={memoryWrapper}
|
bind:this={memoryWrapper}
|
||||||
use:resizeObserver={({ height, width }) => ((viewport.height = height), (viewport.width = width))}
|
bind:clientHeight={viewport.height}
|
||||||
|
bind:clientWidth={viewport.width}
|
||||||
>
|
>
|
||||||
{#if current}
|
{#if current}
|
||||||
<ControlAppBar onClose={() => goto(Route.photos())} forceDark multiRow>
|
<ControlAppBar onClose={() => goto(Route.photos())} forceDark multiRow>
|
||||||
@@ -644,15 +660,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div id="gallery-memory" {@attach galleryObserver} bind:this={memoryGallery}>
|
||||||
id="gallery-memory"
|
|
||||||
use:intersectionObserver={{
|
|
||||||
onIntersect: handleGalleryScrollsIntoView,
|
|
||||||
onSeparate: handleGalleryScrollsOutOfView,
|
|
||||||
bottom: '-200px',
|
|
||||||
}}
|
|
||||||
bind:this={memoryGallery}
|
|
||||||
>
|
|
||||||
<GalleryViewer
|
<GalleryViewer
|
||||||
assets={currentTimelineAssets}
|
assets={currentTimelineAssets}
|
||||||
{viewerAssets}
|
{viewerAssets}
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
<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,7 +57,6 @@
|
|||||||
|
|
||||||
return assetInteraction.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate);
|
return assetInteraction.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate);
|
||||||
});
|
});
|
||||||
const isAllUserOwned = $derived($user && selectedAssets.every((asset) => asset.ownerId === $user.id));
|
|
||||||
|
|
||||||
const handleLink: OnLink = ({ still, motion }) => {
|
const handleLink: OnLink = ({ still, motion }) => {
|
||||||
timelineManager.removeAssets([motion.id]);
|
timelineManager.removeAssets([motion.id]);
|
||||||
@@ -133,7 +132,7 @@
|
|||||||
<SelectAllAssets {timelineManager} {assetInteraction} />
|
<SelectAllAssets {timelineManager} {assetInteraction} />
|
||||||
<ActionButton action={Actions.AddToAlbum} />
|
<ActionButton action={Actions.AddToAlbum} />
|
||||||
|
|
||||||
{#if isAllUserOwned}
|
{#if assetInteraction.isAllUserOwned}
|
||||||
<FavoriteAction
|
<FavoriteAction
|
||||||
removeFavorite={assetInteraction.isAllFavorite}
|
removeFavorite={assetInteraction.isAllFavorite}
|
||||||
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
|
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
|
||||||
|
|||||||
@@ -50,10 +50,7 @@
|
|||||||
|
|
||||||
<svelte:window bind:innerWidth />
|
<svelte:window bind:innerWidth />
|
||||||
|
|
||||||
<nav
|
<nav id="dashboard-navbar" class="max-md:h-(--navbar-height-md) h-(--navbar-height) w-dvw text-sm">
|
||||||
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')} />
|
<SkipLink text={$t('skip_to_content')} />
|
||||||
<div
|
<div
|
||||||
class="grid h-full grid-cols-[--spacing(32)_auto] items-center py-2 sidebar:grid-cols-[--spacing(64)_auto] {noBorder
|
class="grid h-full grid-cols-[--spacing(32)_auto] items-center py-2 sidebar:grid-cols-[--spacing(64)_auto] {noBorder
|
||||||
@@ -83,7 +80,6 @@
|
|||||||
<a data-sveltekit-preload-data="hover" href={Route.photos()}>
|
<a data-sveltekit-preload-data="hover" href={Route.photos()}>
|
||||||
<Logo variant={mediaQueryManager.isFullSidebar ? 'inline' : 'icon'} class="max-md:h-12" />
|
<Logo variant={mediaQueryManager.isFullSidebar ? 'inline' : 'icon'} class="max-md:h-12" />
|
||||||
</a>
|
</a>
|
||||||
<span class="text-xs font-bold text-red-500 ms-2">[VISUAL TEST]</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between gap-4 lg:gap-8 pe-6">
|
<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">
|
<div class="hidden w-full max-w-5xl flex-1 tall:ps-0 sm:block">
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { afterNavigate, beforeNavigate } from '$app/navigation';
|
import { afterNavigate, beforeNavigate } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
|
|
||||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||||
import Month from '$lib/components/timeline/Month.svelte';
|
import Month from '$lib/components/timeline/Month.svelte';
|
||||||
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
|
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
|
||||||
@@ -260,8 +259,6 @@
|
|||||||
const updateIsScrolling = () => (timelineManager.scrolling = true);
|
const updateIsScrolling = () => (timelineManager.scrolling = true);
|
||||||
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
||||||
|
|
||||||
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!enableRouting) {
|
if (!enableRouting) {
|
||||||
invisible = false;
|
invisible = false;
|
||||||
@@ -634,7 +631,7 @@
|
|||||||
style:height={timelineManager.totalViewerHeight + 'px'}
|
style:height={timelineManager.totalViewerHeight + 'px'}
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
use:resizeObserver={topSectionResizeObserver}
|
bind:clientHeight={timelineManager.topSectionHeight}
|
||||||
class:invisible
|
class:invisible
|
||||||
style:position="absolute"
|
style:position="absolute"
|
||||||
style:left="0"
|
style:left="0"
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,12 +32,6 @@ class ImageManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelPreloadUrl(url: string | undefined) {
|
|
||||||
if (url) {
|
|
||||||
cancelImageUrl(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const imageManager = new ImageManager();
|
export const imageManager = new ImageManager();
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { getAssetActions } from '$lib/services/asset.service';
|
||||||
|
import { user as userStore } from '$lib/stores/user.store';
|
||||||
|
import { setSharedLink } from '$lib/utils';
|
||||||
|
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||||
|
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
|
||||||
|
import { userAdminFactory } from '@test-data/factories/user-factory';
|
||||||
|
|
||||||
|
describe('AssetService', () => {
|
||||||
|
describe('getAssetActions', () => {
|
||||||
|
it('should allow shared link downloads if the user owns the asset and shared link downloads are disabled', () => {
|
||||||
|
const ownerId = 'owner';
|
||||||
|
const user = userAdminFactory.build({ id: ownerId });
|
||||||
|
const asset = assetFactory.build({ ownerId });
|
||||||
|
userStore.set(user);
|
||||||
|
setSharedLink(sharedLinkFactory.build({ allowDownload: false }));
|
||||||
|
const assetActions = getAssetActions(() => '', asset);
|
||||||
|
expect(assetActions.SharedLinkDownload.$if?.()).toStrictEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow shared link downloads if the user does not own the asset and shared link downloads are disabled', () => {
|
||||||
|
const ownerId = 'owner';
|
||||||
|
const user = userAdminFactory.build({ id: 'non-owner' });
|
||||||
|
const asset = assetFactory.build({ ownerId });
|
||||||
|
userStore.set(user);
|
||||||
|
setSharedLink(sharedLinkFactory.build({ allowDownload: false }));
|
||||||
|
const assetActions = getAssetActions(() => '', asset);
|
||||||
|
expect(assetActions.SharedLinkDownload.$if?.()).toStrictEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow shared link downloads if shared link downloads are enabled regardless of user', () => {
|
||||||
|
const asset = assetFactory.build();
|
||||||
|
setSharedLink(sharedLinkFactory.build({ allowDownload: true }));
|
||||||
|
const assetActions = getAssetActions(() => '', asset);
|
||||||
|
expect(assetActions.SharedLinkDownload.$if?.()).toStrictEqual(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -106,7 +106,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
|
|||||||
title: $t('share'),
|
title: $t('share'),
|
||||||
icon: mdiShareVariantOutline,
|
icon: mdiShareVariantOutline,
|
||||||
type: $t('assets'),
|
type: $t('assets'),
|
||||||
$if: () => !!(get(authUser) && !asset.isTrashed && asset.visibility !== AssetVisibility.Locked),
|
$if: () => !!(currentAuthUser && !asset.isTrashed && asset.visibility !== AssetVisibility.Locked),
|
||||||
onAction: () => modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] }),
|
onAction: () => modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] }),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
|
|||||||
|
|
||||||
const SharedLinkDownload: ActionItem = {
|
const SharedLinkDownload: ActionItem = {
|
||||||
...Download,
|
...Download,
|
||||||
$if: () => !currentAuthUser && sharedLink && sharedLink.allowDownload,
|
$if: () => isOwner || !!sharedLink?.allowDownload,
|
||||||
};
|
};
|
||||||
|
|
||||||
const PlayMotionPhoto: ActionItem = {
|
const PlayMotionPhoto: ActionItem = {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
AssetMediaSize,
|
AssetMediaSize,
|
||||||
AssetTypeEnum,
|
AssetTypeEnum,
|
||||||
MemoryType,
|
MemoryType,
|
||||||
QueueName,
|
|
||||||
finishOAuth,
|
finishOAuth,
|
||||||
getAssetOriginalPath,
|
getAssetOriginalPath,
|
||||||
getAssetPlaybackPath,
|
getAssetPlaybackPath,
|
||||||
@@ -144,33 +143,6 @@ export const downloadRequest = <TBody = unknown>(options: DownloadRequestOptions
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getQueueName = derived(t, ($t) => {
|
|
||||||
return (name: QueueName) => {
|
|
||||||
const names: Record<QueueName, string> = {
|
|
||||||
[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'),
|
|
||||||
};
|
|
||||||
|
|
||||||
return names[name];
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
let _sharedLink: SharedLinkResponseDto | undefined;
|
let _sharedLink: SharedLinkResponseDto | undefined;
|
||||||
|
|
||||||
export const setSharedLink = (sharedLink: SharedLinkResponseDto) => (_sharedLink = sharedLink);
|
export const setSharedLink = (sharedLink: SharedLinkResponseDto) => (_sharedLink = sharedLink);
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { getContentMetrics, getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
|
||||||
|
|
||||||
|
const mockImage = (props: {
|
||||||
|
naturalWidth: number;
|
||||||
|
naturalHeight: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}): HTMLImageElement => props as unknown as HTMLImageElement;
|
||||||
|
|
||||||
|
const mockVideo = (props: {
|
||||||
|
videoWidth: number;
|
||||||
|
videoHeight: number;
|
||||||
|
clientWidth: number;
|
||||||
|
clientHeight: number;
|
||||||
|
}): HTMLVideoElement => {
|
||||||
|
const element = Object.create(HTMLVideoElement.prototype);
|
||||||
|
for (const [key, value] of Object.entries(props)) {
|
||||||
|
Object.defineProperty(element, key, { value, writable: true, configurable: true });
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('scaleToFit', () => {
|
||||||
|
it('should return full width when image is wider than container', () => {
|
||||||
|
expect(scaleToFit({ width: 2000, height: 1000 }, { width: 800, height: 600 })).toEqual({ width: 800, height: 400 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return full height when image is taller than container', () => {
|
||||||
|
expect(scaleToFit({ width: 1000, height: 2000 }, { width: 800, height: 600 })).toEqual({ width: 300, height: 600 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return exact fit when aspect ratios match', () => {
|
||||||
|
expect(scaleToFit({ width: 1600, height: 900 }, { width: 800, height: 450 })).toEqual({ width: 800, height: 450 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle square images in landscape container', () => {
|
||||||
|
expect(scaleToFit({ width: 500, height: 500 }, { width: 800, height: 600 })).toEqual({ width: 600, height: 600 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle square images in portrait container', () => {
|
||||||
|
expect(scaleToFit({ width: 500, height: 500 }, { width: 400, height: 600 })).toEqual({ width: 400, height: 400 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getContentMetrics', () => {
|
||||||
|
it('should compute zero offsets when aspect ratios match', () => {
|
||||||
|
const img = mockImage({ naturalWidth: 1600, naturalHeight: 900, width: 800, height: 450 });
|
||||||
|
expect(getContentMetrics(img)).toEqual({
|
||||||
|
contentWidth: 800,
|
||||||
|
contentHeight: 450,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should compute horizontal letterbox offsets for tall image', () => {
|
||||||
|
const img = mockImage({ naturalWidth: 1000, naturalHeight: 2000, width: 800, height: 600 });
|
||||||
|
const metrics = getContentMetrics(img);
|
||||||
|
expect(metrics.contentWidth).toBe(300);
|
||||||
|
expect(metrics.contentHeight).toBe(600);
|
||||||
|
expect(metrics.offsetX).toBe(250);
|
||||||
|
expect(metrics.offsetY).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should compute vertical letterbox offsets for wide image', () => {
|
||||||
|
const img = mockImage({ naturalWidth: 2000, naturalHeight: 1000, width: 800, height: 600 });
|
||||||
|
const metrics = getContentMetrics(img);
|
||||||
|
expect(metrics.contentWidth).toBe(800);
|
||||||
|
expect(metrics.contentHeight).toBe(400);
|
||||||
|
expect(metrics.offsetX).toBe(0);
|
||||||
|
expect(metrics.offsetY).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use clientWidth/clientHeight for video elements', () => {
|
||||||
|
const video = mockVideo({ videoWidth: 1920, videoHeight: 1080, clientWidth: 800, clientHeight: 600 });
|
||||||
|
const metrics = getContentMetrics(video);
|
||||||
|
expect(metrics.contentWidth).toBe(800);
|
||||||
|
expect(metrics.contentHeight).toBe(450);
|
||||||
|
expect(metrics.offsetX).toBe(0);
|
||||||
|
expect(metrics.offsetY).toBe(75);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getNaturalSize', () => {
|
||||||
|
it('should return naturalWidth/naturalHeight for images', () => {
|
||||||
|
const img = mockImage({ naturalWidth: 4000, naturalHeight: 3000, width: 800, height: 600 });
|
||||||
|
expect(getNaturalSize(img)).toEqual({ width: 4000, height: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return videoWidth/videoHeight for videos', () => {
|
||||||
|
const video = mockVideo({ videoWidth: 1920, videoHeight: 1080, clientWidth: 800, clientHeight: 600 });
|
||||||
|
expect(getNaturalSize(video)).toEqual({ width: 1920, height: 1080 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export interface ContentMetrics {
|
||||||
|
contentWidth: number;
|
||||||
|
contentHeight: number;
|
||||||
|
offsetX: number;
|
||||||
|
offsetY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const scaleToFit = (
|
||||||
|
dimensions: { width: number; height: number },
|
||||||
|
container: { width: number; height: number },
|
||||||
|
): { width: number; height: number } => {
|
||||||
|
const scaleX = container.width / dimensions.width;
|
||||||
|
const scaleY = container.height / dimensions.height;
|
||||||
|
const scale = Math.min(scaleX, scaleY);
|
||||||
|
return {
|
||||||
|
width: dimensions.width * scale,
|
||||||
|
height: dimensions.height * scale,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getElementSize = (element: HTMLImageElement | HTMLVideoElement): { width: number; height: number } => {
|
||||||
|
if (element instanceof HTMLVideoElement) {
|
||||||
|
return { width: element.clientWidth, height: element.clientHeight };
|
||||||
|
}
|
||||||
|
return { width: element.width, height: element.height };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): { width: number; height: number } => {
|
||||||
|
if (element instanceof HTMLVideoElement) {
|
||||||
|
return { width: element.videoWidth, height: element.videoHeight };
|
||||||
|
}
|
||||||
|
return { width: element.naturalWidth, height: element.naturalHeight };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getContentMetrics = (element: HTMLImageElement | HTMLVideoElement): ContentMetrics => {
|
||||||
|
const natural = getNaturalSize(element);
|
||||||
|
const client = getElementSize(element);
|
||||||
|
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, client);
|
||||||
|
return {
|
||||||
|
contentWidth,
|
||||||
|
contentHeight,
|
||||||
|
offsetX: (client.width - contentWidth) / 2,
|
||||||
|
offsetY: (client.height - contentHeight) / 2,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import type { OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
||||||
|
import type { ContentMetrics } from '$lib/utils/container-utils';
|
||||||
|
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||||
|
|
||||||
|
describe('getOcrBoundingBoxes', () => {
|
||||||
|
it('should scale normalized coordinates by display dimensions', () => {
|
||||||
|
const ocrData: OcrBoundingBox[] = [
|
||||||
|
{
|
||||||
|
id: 'box1',
|
||||||
|
assetId: 'asset1',
|
||||||
|
x1: 0.1,
|
||||||
|
y1: 0.2,
|
||||||
|
x2: 0.9,
|
||||||
|
y2: 0.2,
|
||||||
|
x3: 0.9,
|
||||||
|
y3: 0.8,
|
||||||
|
x4: 0.1,
|
||||||
|
y4: 0.8,
|
||||||
|
boxScore: 0.95,
|
||||||
|
textScore: 0.9,
|
||||||
|
text: 'hello',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const metrics: ContentMetrics = { contentWidth: 1000, contentHeight: 500, offsetX: 0, offsetY: 0 };
|
||||||
|
|
||||||
|
const boxes = getOcrBoundingBoxes(ocrData, metrics);
|
||||||
|
|
||||||
|
expect(boxes).toHaveLength(1);
|
||||||
|
expect(boxes[0].id).toBe('box1');
|
||||||
|
expect(boxes[0].text).toBe('hello');
|
||||||
|
expect(boxes[0].confidence).toBe(0.9);
|
||||||
|
expect(boxes[0].points).toEqual([
|
||||||
|
{ x: 100, y: 100 },
|
||||||
|
{ x: 900, y: 100 },
|
||||||
|
{ x: 900, y: 400 },
|
||||||
|
{ x: 100, y: 400 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply offsets for letterboxed images', () => {
|
||||||
|
const ocrData: OcrBoundingBox[] = [
|
||||||
|
{
|
||||||
|
id: 'box1',
|
||||||
|
assetId: 'asset1',
|
||||||
|
x1: 0,
|
||||||
|
y1: 0,
|
||||||
|
x2: 1,
|
||||||
|
y2: 0,
|
||||||
|
x3: 1,
|
||||||
|
y3: 1,
|
||||||
|
x4: 0,
|
||||||
|
y4: 1,
|
||||||
|
boxScore: 0.9,
|
||||||
|
textScore: 0.8,
|
||||||
|
text: 'test',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const metrics: ContentMetrics = { contentWidth: 600, contentHeight: 400, offsetX: 100, offsetY: 50 };
|
||||||
|
|
||||||
|
const boxes = getOcrBoundingBoxes(ocrData, metrics);
|
||||||
|
|
||||||
|
expect(boxes[0].points).toEqual([
|
||||||
|
{ x: 100, y: 50 },
|
||||||
|
{ x: 700, y: 50 },
|
||||||
|
{ x: 700, y: 450 },
|
||||||
|
{ x: 100, y: 450 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array for empty input', () => {
|
||||||
|
const metrics: ContentMetrics = { contentWidth: 800, contentHeight: 600, offsetX: 0, offsetY: 0 };
|
||||||
|
expect(getOcrBoundingBoxes([], metrics)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple boxes', () => {
|
||||||
|
const ocrData: OcrBoundingBox[] = [
|
||||||
|
{
|
||||||
|
id: 'a',
|
||||||
|
assetId: 'asset1',
|
||||||
|
x1: 0,
|
||||||
|
y1: 0,
|
||||||
|
x2: 0.5,
|
||||||
|
y2: 0,
|
||||||
|
x3: 0.5,
|
||||||
|
y3: 0.5,
|
||||||
|
x4: 0,
|
||||||
|
y4: 0.5,
|
||||||
|
boxScore: 0.9,
|
||||||
|
textScore: 0.8,
|
||||||
|
text: 'first',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'b',
|
||||||
|
assetId: 'asset1',
|
||||||
|
x1: 0.5,
|
||||||
|
y1: 0.5,
|
||||||
|
x2: 1,
|
||||||
|
y2: 0.5,
|
||||||
|
x3: 1,
|
||||||
|
y3: 1,
|
||||||
|
x4: 0.5,
|
||||||
|
y4: 1,
|
||||||
|
boxScore: 0.9,
|
||||||
|
textScore: 0.7,
|
||||||
|
text: 'second',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const metrics: ContentMetrics = { contentWidth: 200, contentHeight: 200, offsetX: 0, offsetY: 0 };
|
||||||
|
|
||||||
|
const boxes = getOcrBoundingBoxes(ocrData, metrics);
|
||||||
|
|
||||||
|
expect(boxes).toHaveLength(2);
|
||||||
|
expect(boxes[0].text).toBe('first');
|
||||||
|
expect(boxes[1].text).toBe('second');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,16 +1,5 @@
|
|||||||
import type { OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
import type { OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
||||||
import type { ZoomImageWheelState } from '@zoom-image/core';
|
import type { ContentMetrics } from '$lib/utils/container-utils';
|
||||||
|
|
||||||
const getContainedSize = (img: HTMLImageElement): { width: number; height: number } => {
|
|
||||||
const ratio = img.naturalWidth / img.naturalHeight;
|
|
||||||
let width = img.height * ratio;
|
|
||||||
let height = img.height;
|
|
||||||
if (width > img.width) {
|
|
||||||
width = img.width;
|
|
||||||
height = img.width / ratio;
|
|
||||||
}
|
|
||||||
return { width, height };
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Point = {
|
export type Point = {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -66,53 +55,17 @@ export const calculateBoundingBoxMatrix = (points: Point[]): { matrix: number[];
|
|||||||
return { matrix, width, height };
|
return { matrix, width, height };
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export const getOcrBoundingBoxes = (ocrData: OcrBoundingBox[], metrics: ContentMetrics): OcrBox[] => {
|
||||||
* Convert normalized OCR coordinates to screen coordinates
|
|
||||||
* OCR coordinates are normalized (0-1) and represent the 4 corners of a rotated rectangle
|
|
||||||
*/
|
|
||||||
export const getOcrBoundingBoxes = (
|
|
||||||
ocrData: OcrBoundingBox[],
|
|
||||||
zoom: ZoomImageWheelState,
|
|
||||||
photoViewer: HTMLImageElement | null,
|
|
||||||
): OcrBox[] => {
|
|
||||||
if (photoViewer === null || !photoViewer.naturalWidth || !photoViewer.naturalHeight) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientHeight = photoViewer.clientHeight;
|
|
||||||
const clientWidth = photoViewer.clientWidth;
|
|
||||||
const { width, height } = getContainedSize(photoViewer);
|
|
||||||
|
|
||||||
const offset = {
|
|
||||||
x: ((clientWidth - width) / 2) * zoom.currentZoom + zoom.currentPositionX,
|
|
||||||
y: ((clientHeight - height) / 2) * zoom.currentZoom + zoom.currentPositionY,
|
|
||||||
};
|
|
||||||
|
|
||||||
return getOcrBoundingBoxesAtSize(
|
|
||||||
ocrData,
|
|
||||||
{ width: width * zoom.currentZoom, height: height * zoom.currentZoom },
|
|
||||||
offset,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getOcrBoundingBoxesAtSize = (
|
|
||||||
ocrData: OcrBoundingBox[],
|
|
||||||
targetSize: { width: number; height: number },
|
|
||||||
offset?: Point,
|
|
||||||
) => {
|
|
||||||
const boxes: OcrBox[] = [];
|
const boxes: OcrBox[] = [];
|
||||||
|
|
||||||
for (const ocr of ocrData) {
|
for (const ocr of ocrData) {
|
||||||
// Convert normalized coordinates (0-1) to actual pixel positions
|
|
||||||
// OCR provides 4 corners of a potentially rotated rectangle
|
|
||||||
const points = [
|
const points = [
|
||||||
{ x: ocr.x1, y: ocr.y1 },
|
{ x: ocr.x1, y: ocr.y1 },
|
||||||
{ x: ocr.x2, y: ocr.y2 },
|
{ x: ocr.x2, y: ocr.y2 },
|
||||||
{ x: ocr.x3, y: ocr.y3 },
|
{ x: ocr.x3, y: ocr.y3 },
|
||||||
{ x: ocr.x4, y: ocr.y4 },
|
{ x: ocr.x4, y: ocr.y4 },
|
||||||
].map((point) => ({
|
].map((point) => ({
|
||||||
x: targetSize.width * point.x + (offset?.x ?? 0),
|
x: point.x * metrics.contentWidth + metrics.offsetX,
|
||||||
y: targetSize.height * point.y + (offset?.y ?? 0),
|
y: point.y * metrics.contentHeight + metrics.offsetY,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
boxes.push({
|
boxes.push({
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { Faces } from '$lib/stores/people.store';
|
||||||
|
import type { ContentMetrics } from '$lib/utils/container-utils';
|
||||||
|
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||||
|
|
||||||
|
const makeFace = (overrides: Partial<Faces> = {}): Faces => ({
|
||||||
|
imageWidth: 4000,
|
||||||
|
imageHeight: 3000,
|
||||||
|
boundingBoxX1: 1000,
|
||||||
|
boundingBoxY1: 750,
|
||||||
|
boundingBoxX2: 2000,
|
||||||
|
boundingBoxY2: 1500,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getBoundingBox', () => {
|
||||||
|
it('should scale face coordinates to display dimensions', () => {
|
||||||
|
const face = makeFace();
|
||||||
|
const metrics: ContentMetrics = { contentWidth: 800, contentHeight: 600, offsetX: 0, offsetY: 0 };
|
||||||
|
|
||||||
|
const boxes = getBoundingBox([face], metrics);
|
||||||
|
|
||||||
|
expect(boxes).toHaveLength(1);
|
||||||
|
expect(boxes[0]).toEqual({
|
||||||
|
top: Math.round(600 * (750 / 3000)),
|
||||||
|
left: Math.round(800 * (1000 / 4000)),
|
||||||
|
width: Math.round(800 * (2000 / 4000) - 800 * (1000 / 4000)),
|
||||||
|
height: Math.round(600 * (1500 / 3000) - 600 * (750 / 3000)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply offsets for letterboxed display', () => {
|
||||||
|
const face = makeFace({
|
||||||
|
imageWidth: 1000,
|
||||||
|
imageHeight: 1000,
|
||||||
|
boundingBoxX1: 0,
|
||||||
|
boundingBoxY1: 0,
|
||||||
|
boundingBoxX2: 1000,
|
||||||
|
boundingBoxY2: 1000,
|
||||||
|
});
|
||||||
|
const metrics: ContentMetrics = { contentWidth: 600, contentHeight: 600, offsetX: 100, offsetY: 0 };
|
||||||
|
|
||||||
|
const boxes = getBoundingBox([face], metrics);
|
||||||
|
|
||||||
|
expect(boxes[0]).toEqual({
|
||||||
|
top: 0,
|
||||||
|
left: 100,
|
||||||
|
width: 600,
|
||||||
|
height: 600,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zoom by pre-scaled metrics', () => {
|
||||||
|
const face = makeFace({
|
||||||
|
imageWidth: 1000,
|
||||||
|
imageHeight: 1000,
|
||||||
|
boundingBoxX1: 0,
|
||||||
|
boundingBoxY1: 0,
|
||||||
|
boundingBoxX2: 500,
|
||||||
|
boundingBoxY2: 500,
|
||||||
|
});
|
||||||
|
const metrics: ContentMetrics = {
|
||||||
|
contentWidth: 1600,
|
||||||
|
contentHeight: 1200,
|
||||||
|
offsetX: -200,
|
||||||
|
offsetY: -100,
|
||||||
|
};
|
||||||
|
|
||||||
|
const boxes = getBoundingBox([face], metrics);
|
||||||
|
|
||||||
|
expect(boxes[0]).toEqual({
|
||||||
|
top: -100,
|
||||||
|
left: -200,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array for empty faces', () => {
|
||||||
|
const metrics: ContentMetrics = { contentWidth: 800, contentHeight: 600, offsetX: 0, offsetY: 0 };
|
||||||
|
expect(getBoundingBox([], metrics)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple faces', () => {
|
||||||
|
const faces = [
|
||||||
|
makeFace({ boundingBoxX1: 0, boundingBoxY1: 0, boundingBoxX2: 1000, boundingBoxY2: 1000 }),
|
||||||
|
makeFace({ boundingBoxX1: 2000, boundingBoxY1: 1500, boundingBoxX2: 3000, boundingBoxY2: 2500 }),
|
||||||
|
];
|
||||||
|
const metrics: ContentMetrics = { contentWidth: 800, contentHeight: 600, offsetX: 0, offsetY: 0 };
|
||||||
|
|
||||||
|
const boxes = getBoundingBox(faces, metrics);
|
||||||
|
|
||||||
|
expect(boxes).toHaveLength(2);
|
||||||
|
expect(boxes[0].left).toBeLessThan(boxes[1].left);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,65 +1,27 @@
|
|||||||
import type { Faces } from '$lib/stores/people.store';
|
import type { Faces } from '$lib/stores/people.store';
|
||||||
import { getAssetMediaUrl } from '$lib/utils';
|
import { getAssetMediaUrl } from '$lib/utils';
|
||||||
|
import type { ContentMetrics } from '$lib/utils/container-utils';
|
||||||
import { AssetTypeEnum, type AssetFaceResponseDto } from '@immich/sdk';
|
import { AssetTypeEnum, type AssetFaceResponseDto } from '@immich/sdk';
|
||||||
import type { ZoomImageWheelState } from '@zoom-image/core';
|
|
||||||
|
|
||||||
const getContainedSize = (img: HTMLImageElement): { width: number; height: number } => {
|
export interface BoundingBox {
|
||||||
const ratio = img.naturalWidth / img.naturalHeight;
|
|
||||||
let width = img.height * ratio;
|
|
||||||
let height = img.height;
|
|
||||||
if (width > img.width) {
|
|
||||||
width = img.width;
|
|
||||||
height = img.width / ratio;
|
|
||||||
}
|
|
||||||
return { width, height };
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface boundingBox {
|
|
||||||
top: number;
|
top: number;
|
||||||
left: number;
|
left: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getBoundingBox = (
|
export const getBoundingBox = (faces: Faces[], metrics: ContentMetrics): BoundingBox[] => {
|
||||||
faces: Faces[],
|
const boxes: BoundingBox[] = [];
|
||||||
zoom: ZoomImageWheelState,
|
|
||||||
photoViewer: HTMLImageElement | undefined,
|
|
||||||
): boundingBox[] => {
|
|
||||||
const boxes: boundingBox[] = [];
|
|
||||||
|
|
||||||
if (!photoViewer) {
|
|
||||||
return boxes;
|
|
||||||
}
|
|
||||||
const clientHeight = photoViewer.clientHeight;
|
|
||||||
const clientWidth = photoViewer.clientWidth;
|
|
||||||
|
|
||||||
const { width, height } = getContainedSize(photoViewer);
|
|
||||||
|
|
||||||
for (const face of faces) {
|
for (const face of faces) {
|
||||||
/*
|
const scaleX = metrics.contentWidth / face.imageWidth;
|
||||||
*
|
const scaleY = metrics.contentHeight / face.imageHeight;
|
||||||
* Create the coordinates of the box based on the displayed image.
|
|
||||||
* The coordinates must take into account margins due to the 'object-fit: contain;' css property of the photo-viewer.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
const coordinates = {
|
const coordinates = {
|
||||||
x1:
|
x1: scaleX * face.boundingBoxX1 + metrics.offsetX,
|
||||||
(width / face.imageWidth) * zoom.currentZoom * face.boundingBoxX1 +
|
x2: scaleX * face.boundingBoxX2 + metrics.offsetX,
|
||||||
((clientWidth - width) / 2) * zoom.currentZoom +
|
y1: scaleY * face.boundingBoxY1 + metrics.offsetY,
|
||||||
zoom.currentPositionX,
|
y2: scaleY * face.boundingBoxY2 + metrics.offsetY,
|
||||||
x2:
|
|
||||||
(width / face.imageWidth) * zoom.currentZoom * face.boundingBoxX2 +
|
|
||||||
((clientWidth - width) / 2) * zoom.currentZoom +
|
|
||||||
zoom.currentPositionX,
|
|
||||||
y1:
|
|
||||||
(height / face.imageHeight) * zoom.currentZoom * face.boundingBoxY1 +
|
|
||||||
((clientHeight - height) / 2) * zoom.currentZoom +
|
|
||||||
zoom.currentPositionY,
|
|
||||||
y2:
|
|
||||||
(height / face.imageHeight) * zoom.currentZoom * face.boundingBoxY2 +
|
|
||||||
((clientHeight - height) / 2) * zoom.currentZoom +
|
|
||||||
zoom.currentPositionY,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
boxes.push({
|
boxes.push({
|
||||||
@@ -69,6 +31,7 @@ export const getBoundingBox = (
|
|||||||
height: Math.round(coordinates.y2 - coordinates.y1),
|
height: Math.round(coordinates.y2 - coordinates.y1),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return boxes;
|
return boxes;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -61,8 +61,6 @@
|
|||||||
return assetInteraction.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate);
|
return assetInteraction.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate);
|
||||||
});
|
});
|
||||||
|
|
||||||
const isAllUserOwned = $derived($user && selectedAssets.every((asset) => asset.ownerId === $user.id));
|
|
||||||
|
|
||||||
const handleEscape = () => {
|
const handleEscape = () => {
|
||||||
if ($showAssetViewer) {
|
if ($showAssetViewer) {
|
||||||
return;
|
return;
|
||||||
@@ -135,7 +133,7 @@
|
|||||||
<SelectAllAssets {timelineManager} {assetInteraction} />
|
<SelectAllAssets {timelineManager} {assetInteraction} />
|
||||||
<ActionButton action={Actions.AddToAlbum} />
|
<ActionButton action={Actions.AddToAlbum} />
|
||||||
|
|
||||||
{#if isAllUserOwned}
|
{#if assetInteraction.isAllUserOwned}
|
||||||
<FavoriteAction
|
<FavoriteAction
|
||||||
removeFavorite={assetInteraction.isAllFavorite}
|
removeFavorite={assetInteraction.isAllFavorite}
|
||||||
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
|
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
import { getAssetBulkActions } from '$lib/services/asset.service';
|
import { getAssetBulkActions } from '$lib/services/asset.service';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { lang, locale } from '$lib/stores/preferences.store';
|
import { lang, locale } from '$lib/stores/preferences.store';
|
||||||
import { preferences, user } from '$lib/stores/user.store';
|
import { preferences } from '$lib/stores/user.store';
|
||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||||
import { parseUtcDate } from '$lib/utils/date-time';
|
import { parseUtcDate } from '$lib/utils/date-time';
|
||||||
@@ -69,10 +69,6 @@
|
|||||||
let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
|
let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
|
||||||
let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {});
|
let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {});
|
||||||
|
|
||||||
const isAllUserOwned = $derived(
|
|
||||||
$user && assetInteraction.selectedAssets.every((asset) => asset.ownerId === $user.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// we want this to *only* be reactive on `terms`
|
// we want this to *only* be reactive on `terms`
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
@@ -326,7 +322,7 @@
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
{#if assetInteraction.selectionActive}
|
{#if assetInteraction.selectionActive}
|
||||||
<div class="fixed top-0 start-0 w-full">
|
<div class="fixed top-0 start-0 w-full z-2">
|
||||||
<AssetSelectControlBar
|
<AssetSelectControlBar
|
||||||
assets={assetInteraction.selectedAssets}
|
assets={assetInteraction.selectedAssets}
|
||||||
clearSelect={() => cancelMultiselect(assetInteraction)}
|
clearSelect={() => cancelMultiselect(assetInteraction)}
|
||||||
@@ -344,7 +340,7 @@
|
|||||||
onclick={handleSelectAll}
|
onclick={handleSelectAll}
|
||||||
/>
|
/>
|
||||||
<ActionButton action={Actions.AddToAlbum} />
|
<ActionButton action={Actions.AddToAlbum} />
|
||||||
{#if isAllUserOwned}
|
{#if assetInteraction.isAllUserOwned}
|
||||||
<FavoriteAction
|
<FavoriteAction
|
||||||
removeFavorite={assetInteraction.isAllFavorite}
|
removeFavorite={assetInteraction.isAllFavorite}
|
||||||
onFavorite={(ids, isFavorite) => {
|
onFavorite={(ids, isFavorite) => {
|
||||||
@@ -364,10 +360,8 @@
|
|||||||
<ChangeDescription menuItem />
|
<ChangeDescription menuItem />
|
||||||
<ChangeLocation menuItem />
|
<ChangeLocation menuItem />
|
||||||
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
|
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
|
||||||
{#if assetInteraction.isAllUserOwned}
|
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
{#if $preferences.tags.enabled}
|
||||||
{/if}
|
|
||||||
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
|
||||||
<TagAction menuItem />
|
<TagAction menuItem />
|
||||||
{/if}
|
{/if}
|
||||||
<DeleteAssets menuItem {onAssetDelete} onUndoDelete={onSearchQueryUpdate} />
|
<DeleteAssets menuItem {onAssetDelete} onUndoDelete={onSearchQueryUpdate} />
|
||||||
@@ -382,7 +376,7 @@
|
|||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="fixed top-0 start-0 w-full">
|
<div class="fixed top-0 start-0 w-full z-2">
|
||||||
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
|
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
|
||||||
<div class="absolute bg-light"></div>
|
<div class="absolute bg-light"></div>
|
||||||
<div class="w-full flex-1 ps-4">
|
<div class="w-full flex-1 ps-4">
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
import { shortcut } from '$lib/actions/shortcut';
|
import { shortcut } from '$lib/actions/shortcut';
|
||||||
import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
|
import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
|
||||||
import ErrorLayout from '$lib/components/layouts/ErrorLayout.svelte';
|
import ErrorLayout from '$lib/components/layouts/ErrorLayout.svelte';
|
||||||
import AppleHeader from '$lib/components/shared-components/apple-header.svelte';
|
|
||||||
import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte';
|
import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte';
|
||||||
import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
|
import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
|
||||||
import VersionAnnouncement from '$lib/components/VersionAnnouncement.svelte';
|
import VersionAnnouncement from '$lib/components/VersionAnnouncement.svelte';
|
||||||
@@ -189,8 +188,8 @@
|
|||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{page.data.meta?.title || 'Web'} - Immich</title>
|
<title>{page.data.meta?.title || 'Web'} - Immich</title>
|
||||||
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
|
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
|
||||||
<meta name="theme-color" content="currentColor" />
|
<meta name="theme-color" content="white" media="(prefers-color-scheme: light)" />
|
||||||
<AppleHeader />
|
<meta name="theme-color" content="black" media="(prefers-color-scheme: dark)" />
|
||||||
|
|
||||||
{#if page.data.meta}
|
{#if page.data.meta}
|
||||||
<meta name="description" content={page.data.meta.description} />
|
<meta name="description" content={page.data.meta.description} />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
"description": "Self-hosted photo and video backup solution directly from your mobile phone.",
|
"description": "Self-hosted photo and video backup solution directly from your mobile phone.",
|
||||||
|
"dir": "auto",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
@@ -9,12 +10,6 @@
|
|||||||
"src": "manifest-icon-192.maskable.png",
|
"src": "manifest-icon-192.maskable.png",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"purpose": "maskable",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"src": "manifest-icon-192.maskable.png",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"purpose": "any",
|
"purpose": "any",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
@@ -31,5 +26,19 @@
|
|||||||
"lang": "en",
|
"lang": "en",
|
||||||
"name": "Immich",
|
"name": "Immich",
|
||||||
"short_name": "Immich",
|
"short_name": "Immich",
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Photos",
|
||||||
|
"url": "/photos"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Albums",
|
||||||
|
"url": "/albums"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Map",
|
||||||
|
"url": "/map"
|
||||||
|
}
|
||||||
|
],
|
||||||
"start_url": "/"
|
"start_url": "/"
|
||||||
}
|
}
|
||||||
|
|||||||