Compare commits

..

3 Commits

Author SHA1 Message Date
midzelis
6a38b2690e fix(web): preserve stacked asset selection when tagging faces
Change-Id: Iec1507560f99f2e9433bd5cf6b460b176a6a6964
2026-04-25 16:11:34 +00:00
Savely Krasovsky
9263e2f2e1 feat(ml): update Intel graphics compiler and compute runtime (#28076)
feat(ml): update Intel graphics compiler and compute runtime to latest versions
2026-04-25 08:49:57 -04:00
Aaron Liu
a3ee615c5b chore(ml): update huggingfacehub and pillow (#27552) 2026-04-24 19:44:01 -04:00
11 changed files with 98 additions and 166 deletions

View File

@@ -143,9 +143,9 @@ jobs:
ALIAS: ${{ secrets.ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
IS_RELEASE: ${{ inputs.environment == 'production' || github.ref == 'refs/heads/main' }}
IS_MAIN: ${{ github.ref == 'refs/heads/main' }}
run: |
if [[ $IS_RELEASE == 'true' ]]; then
if [[ $IS_MAIN == 'true' ]]; then
flutter build apk --release
flutter build apk --release --split-per-abi --target-platform android-arm,android-arm64,android-x64
else
@@ -268,20 +268,20 @@ jobs:
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
ENVIRONMENT: ${{ inputs.environment || 'development' }}
BUNDLE_ID_SUFFIX: ${{ inputs.environment == 'production' && '' || 'development' }}
IS_RELEASE: ${{ inputs.environment == 'production' || github.ref == 'refs/heads/main' }}
GITHUB_REF: ${{ github.ref }}
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 120
FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 6
working-directory: ./mobile/ios
run: |
# Upload to TestFlight on main or when explicitly invoked as a production release.
if [[ "$IS_RELEASE" == "true" ]]; then
# Only upload to TestFlight on main branch
if [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
if [[ "$ENVIRONMENT" == "development" ]]; then
bundle exec fastlane gha_testflight_dev
else
bundle exec fastlane gha_release_prod
fi
else
# Build only, no TestFlight upload
# Build only, no TestFlight upload for non-main branches
bundle exec fastlane gha_build_only
fi

View File

@@ -3,7 +3,7 @@ name: Docker
on:
workflow_dispatch:
push:
branches: [main, 'release/**']
branches: [main]
pull_request:
release:
types: [published]
@@ -53,8 +53,7 @@ jobs:
permissions:
contents: read
packages: write
# Retag sources from the :main image, so only retag for PRs and main-branch pushes.
if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == false && !github.event.pull_request.head.repo.fork && (github.event_name == 'pull_request' || github.ref == 'refs/heads/main') }}
if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == false && !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
strategy:
matrix:
@@ -84,8 +83,7 @@ jobs:
permissions:
contents: read
packages: write
# Retag sources from the :main image, so only retag for PRs and main-branch pushes.
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == false && !github.event.pull_request.head.repo.fork && (github.event_name == 'pull_request' || github.ref == 'refs/heads/main') }}
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == false && !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
strategy:
matrix:

View File

@@ -1,7 +1,7 @@
name: Docs build
on:
push:
branches: [main, 'release/**']
branches: [main]
pull_request:
release:
types: [published]
@@ -39,7 +39,7 @@ jobs:
force-filters: |
- '.github/workflows/docs-build.yml'
force-events: 'release'
force-branches: 'main,release/**'
force-branches: 'main'
build:
name: Docs Build

View File

@@ -3,13 +3,8 @@ name: Prepare new release
on:
workflow_dispatch:
inputs:
branch:
description: 'Branch to release from (must be main or release/*)'
required: true
default: 'main'
type: string
serverBump:
description: 'Bump server version (only patch allowed on release/* branches)'
description: 'Bump server version'
required: true
default: 'false'
type: choice
@@ -34,31 +29,10 @@ concurrency:
permissions: {}
jobs:
validate_inputs:
runs-on: ubuntu-latest
permissions: {}
steps:
- name: Validate branch and bump combination
env:
BRANCH: ${{ inputs.branch }}
SERVER_BUMP: ${{ inputs.serverBump }}
run: |
set -euo pipefail
if [[ "$BRANCH" != "main" && "$BRANCH" != release/* ]]; then
echo "::error::branch must be 'main' or start with 'release/' (got '$BRANCH')"
exit 1
fi
if [[ "$BRANCH" != "main" && "$SERVER_BUMP" != "false" && "$SERVER_BUMP" != "patch" ]]; then
echo "::error::only 'patch' (or 'false') serverBump is allowed on '$BRANCH'"
exit 1
fi
merge_translations:
needs: [validate_inputs]
uses: ./.github/workflows/merge-translations.yml
with:
# Weblate tracks main only, so skip translations when releasing from a release/* branch.
skip: ${{ inputs.skipTranslations || inputs.branch != 'main' }}
skip: ${{ inputs.skipTranslations }}
permissions:
pull-requests: write
secrets:
@@ -86,7 +60,7 @@ jobs:
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true
ref: ${{ inputs.branch }}
ref: main
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
@@ -120,10 +94,6 @@ jobs:
push: true
build_mobile:
# Mobile build numbers are monotonic per store; releasing from a release/* branch
# would collide with build numbers already shipped from main. Skip mobile on patch
# releases — handle mobile patches on main instead.
if: ${{ inputs.branch == 'main' }}
uses: ./.github/workflows/build-mobile.yml
needs: bump_version
permissions:
@@ -148,8 +118,6 @@ jobs:
prepare_release:
runs-on: ubuntu-latest
needs: [build_mobile, bump_version]
# Run even when build_mobile is skipped (patch release from release/* branch).
if: ${{ always() && needs.bump_version.result == 'success' && (needs.build_mobile.result == 'success' || needs.build_mobile.result == 'skipped') }}
permissions:
actions: read # To download the app artifact
# No content permissions are needed because it uses the app-token
@@ -166,83 +134,26 @@ jobs:
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false
ref: ${{ needs.bump_version.outputs.ref }}
- name: Download APK
if: ${{ needs.build_mobile.result == 'success' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }}
- name: Assemble release assets
id: assets
env:
HAS_APK: ${{ needs.build_mobile.result == 'success' }}
run: |
{
echo 'files<<EOF'
echo 'docker/docker-compose.yml'
echo 'docker/docker-compose.rootless.yml'
echo 'docker/example.env'
echo 'docker/hwaccel.ml.yml'
echo 'docker/hwaccel.transcoding.yml'
echo 'docker/prometheus.yml'
if [[ "$HAS_APK" == "true" ]]; then
echo '*.apk'
fi
echo 'EOF'
} >> "$GITHUB_OUTPUT"
- name: Create draft release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
with:
draft: true
tag_name: ${{ needs.bump_version.outputs.version }}
target_commitish: ${{ inputs.branch }}
token: ${{ steps.generate-token.outputs.token }}
generate_release_notes: true
body_path: misc/release/notes.tmpl
files: ${{ steps.assets.outputs.files }}
backport_archived_versions:
# When releasing from a release/* branch, the archived-versions.json update
# lives on that branch only. Open a PR to mirror the new entry onto main so
# main's docs keep a complete archive list.
if: ${{ inputs.branch != 'main' && needs.bump_version.result == 'success' }}
runs-on: ubuntu-latest
needs: [bump_version, prepare_release]
permissions: {} # uses the app token
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout main
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false
ref: main
- name: Update archived versions on main
env:
VERSION: ${{ needs.bump_version.outputs.version }}
run: ./misc/release/archive-version.js "${VERSION#v}"
- name: Open backport PR
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ steps.generate-token.outputs.token }}
branch: backport/archived-versions-${{ needs.bump_version.outputs.version }}
base: main
commit-message: 'chore(docs): archive ${{ needs.bump_version.outputs.version }}'
title: 'chore(docs): archive ${{ needs.bump_version.outputs.version }}'
body: |
Backports the `archived-versions.json` entry for ${{ needs.bump_version.outputs.version }},
released from `${{ inputs.branch }}`, so main's docs archive list stays complete.
add-paths: docs/static/archived-versions.json
delete-branch: true
files: |
docker/docker-compose.yml
docker/docker-compose.rootless.yml
docker/example.env
docker/hwaccel.ml.yml
docker/hwaccel.transcoding.yml
docker/prometheus.yml
*.apk

View File

@@ -48,14 +48,14 @@ FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-core-2_2.28.4+20760_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-opencl-2_2.28.4+20760_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/intel-opencl-icd_26.05.37020.3-0_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-core-2_2.32.7+21184_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/libigdgmm12_22.9.0_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb && \
dpkg -i *.deb && \
rm *.deb && \
apt-get remove wget -yqq && \

View File

@@ -9,12 +9,12 @@ dependencies = [
"aiocache>=0.12.1,<1.0",
"fastapi>=0.95.2,<1.0",
"gunicorn>=21.1.0",
"huggingface-hub>=0.20.1,<1.0",
"huggingface-hub>=1.0,<2.0",
"insightface>=0.7.3,<1.0",
"numpy<2.4.0",
"opencv-python-headless>=4.7.0.72,<5.0",
"orjson>=3.9.5",
"pillow>=12.2,<12.3",
"pillow>=12.2,<13",
"pydantic>=2.0.0,<3",
"pydantic-settings>=2.5.2,<3",
"python-multipart>=0.0.6,<1.0",

View File

@@ -9,6 +9,7 @@
import OnEvents from '$lib/components/OnEvents.svelte';
import { AssetAction, ProjectionType } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
@@ -99,9 +100,10 @@
const stackSelectedThumbnailSize = 65;
let previewStackedAsset: AssetResponseDto | undefined = $state();
let stack: StackResponseDto | null = $state(null);
let stack: StackResponseDto | undefined = $state();
let selectedStackAsset: AssetResponseDto | undefined = $state();
const asset = $derived(previewStackedAsset ?? cursor.current);
const asset = $derived(previewStackedAsset ?? selectedStackAsset ?? cursor.current);
const nextAsset = $derived(cursor.nextAsset);
const previousAsset = $derived(cursor.previousAsset);
let sharedLink = getSharedLink();
@@ -114,17 +116,29 @@
playOriginalVideo = value;
};
const selectStackedAsset = async (id: string) => {
ocrManager.clear();
selectedStackAsset = await assetCacheManager.getAsset({ id });
if (!sharedLink) {
await ocrManager.getAssetOcr(id);
}
};
const refreshStack = async () => {
if (authManager.isSharedLink || !withStacked) {
return;
}
if (asset.stack) {
stack = await getStack({ id: asset.stack.id });
if (!cursor.current.stack) {
stack = undefined;
selectedStackAsset = undefined;
return;
}
if (!stack?.assets.some(({ id }) => id === asset.id)) {
stack = null;
stack = await getStack({ id: cursor.current.stack.id });
const primaryAsset = stack?.assets.find(({ id }) => id === stack?.primaryAssetId);
if (primaryAsset) {
await selectStackedAsset(primaryAsset.id);
}
};
@@ -182,11 +196,21 @@
onClose?.(asset);
};
const refreshPreservingSelection = async () => {
const id = asset.id;
assetCacheManager.invalidateAsset(id);
if (selectedStackAsset) {
await selectStackedAsset(id);
} else {
const refreshedAsset = await assetCacheManager.getAsset({ id });
assetViewerManager.setAsset(refreshedAsset);
}
onAssetChange?.(asset);
};
const closeEditor = async () => {
if (editManager.hasAppliedEdits) {
const refreshedAsset = await getAssetInfo({ id: asset.id });
onAssetChange?.(refreshedAsset);
assetViewerManager.setAsset(refreshedAsset);
await refreshPreservingSelection();
}
assetViewerManager.closeEditor();
};
@@ -285,10 +309,6 @@
}
};
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
previewStackedAsset = isMouseOver ? stackedAsset : undefined;
};
const handlePreAction = (action: Action) => {
preAction?.(action);
};
@@ -301,7 +321,7 @@
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
stack = action.stack;
stack = action.stack ?? undefined;
if (stack) {
cursor.current = stack.assets[0];
}
@@ -309,7 +329,7 @@
}
case AssetAction.STACK:
case AssetAction.SET_STACK_PRIMARY_ASSET: {
stack = action.stack;
stack = action.stack ?? undefined;
break;
}
case AssetAction.SET_PERSON_FEATURED_PHOTO: {
@@ -368,7 +388,7 @@
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset;
cursor.current;
untrack(() => handlePromiseError(refresh()));
});
@@ -533,7 +553,12 @@
{:else if viewerKind === 'CropArea'}
<CropArea {asset} />
{:else if viewerKind === 'PhotoViewer'}
<PhotoViewer cursor={{ ...cursor, current: asset }} {sharedLink} {onSwipe} />
<PhotoViewer
cursor={{ ...cursor, current: asset }}
{sharedLink}
{onSwipe}
onTagFace={refreshPreservingSelection}
/>
{:else if viewerKind === 'VideoViewer'}
<VideoViewer
{asset}
@@ -585,7 +610,7 @@
translate="yes"
>
{#if showDetailPanel}
<DetailPanel {asset} currentAlbum={album} />
<DetailPanel {asset} currentAlbum={album} onRefreshPeople={refreshPreservingSelection} />
{:else if assetViewerManager.isShowEditor}
<EditorPanel {asset} onClose={closeEditor} />
{/if}
@@ -597,27 +622,24 @@
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
{#each stackedAssets as stackedAsset (stackedAsset.id)}
{@const isSelected = stackedAsset.id === (selectedStackAsset?.id ?? cursor.current.id)}
<div
class={['inline-block px-1 relative transition-all pb-2 pointer-events-auto']}
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
style:bottom={isSelected ? '0' : '-10px'}
>
<Thumbnail
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }}
imageClass={{ 'border-2 border-white': isSelected }}
brokenAssetClass="text-xs"
dimmed={stackedAsset.id !== asset.id}
dimmed={!isSelected}
asset={toTimelineAsset(stackedAsset)}
onClick={() => {
cursor.current = stackedAsset;
previewStackedAsset = undefined;
}}
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
onClick={() => selectStackedAsset(stackedAsset.id)}
readonly
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
thumbnailSize={isSelected ? stackSelectedThumbnailSize : stackThumbnailSize}
showStackedIcon={false}
disableLinkMouseOver
/>
{#if stackedAsset.id === asset.id}
{#if isSelected}
<div class="w-full flex place-items-center place-content-center">
<div class="w-2 h-2 bg-white rounded-full flex mt-0.5"></div>
</div>

View File

@@ -17,13 +17,7 @@
import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
import { getParentPath } from '$lib/utils/tree-utils';
import {
AssetMediaSize,
getAllAlbums,
getAssetInfo,
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
import { AssetMediaSize, getAllAlbums, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, Text } from '@immich/ui';
import {
mdiCamera,
@@ -49,9 +43,10 @@
interface Props {
asset: AssetResponseDto;
currentAlbum?: AlbumResponseDto | null;
onRefreshPeople?: () => Promise<void>;
}
let { asset, currentAlbum = null }: Props = $props();
let { asset, currentAlbum = null, onRefreshPeople }: Props = $props();
let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId);
let people = $derived(asset.people || []);
@@ -109,11 +104,6 @@
return undefined;
};
const handleRefreshPeople = async () => {
asset = await getAssetInfo({ id: asset.id });
assetViewerManager.closeEditFacesPanel();
};
const getAssetFolderHref = (asset: AssetResponseDto) => {
// Remove the last part of the path to get the parent path
return Route.folders({ path: getParentPath(asset.originalPath) });
@@ -501,6 +491,6 @@
assetId={asset.id}
assetType={asset.type}
onClose={() => assetViewerManager.closeEditFacesPanel()}
onRefresh={handleRefreshPeople}
onRefresh={() => void onRefreshPeople?.()}
/>
{/if}

View File

@@ -31,9 +31,10 @@
onReady?: () => void;
onError?: () => void;
onSwipe?: (event: SwipeCustomEvent) => void;
onTagFace?: () => Promise<void>;
};
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe, onTagFace }: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore;
const asset = $derived(cursor.current);
@@ -285,6 +286,12 @@
</AdaptiveImage>
{#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef}
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
<FaceEditor
htmlElement={assetViewerManager.imgRef}
{containerWidth}
{containerHeight}
assetId={asset.id}
{onTagFace}
/>
{/if}
</div>

View File

@@ -18,9 +18,10 @@
containerWidth: number;
containerHeight: number;
assetId: string;
onTagFace?: () => Promise<void>;
};
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props();
let { htmlElement, containerWidth, containerHeight, assetId, onTagFace }: Props = $props();
let canvasEl: HTMLCanvasElement | undefined = $state();
let canvas: Canvas | undefined = $state();
@@ -325,7 +326,7 @@
},
});
await assetViewerManager.setAssetId(assetId);
await onTagFace?.();
} catch (error) {
handleError(error, 'Error tagging face');
} finally {

View File

@@ -179,7 +179,10 @@
peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id);
await assetViewerManager.setAssetId(assetId);
onRefresh();
if (peopleWithFaces.length === 0) {
onClose();
}
} catch (error) {
handleError(error, $t('error_delete_face'));
}