Compare commits

...

7 Commits

Author SHA1 Message Date
renovate[bot]
d3b115447d chore(deps): update machine-learning 2026-07-24 18:07:02 +00:00
Adam Gastineau
9abe72aced fix(mobile): align iOS widget text box with widget's corners (#30176)
* fix(mobile): align iOS widget text box with widget's corners

* Improve image centering and fix formatting

* Remove duplicate preview
2026-07-24 23:34:27 +05:30
shenlong
829b4e748e feat: button longpress handler (#30200)
* feat: button longpress handler

* review suggestion

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-24 23:34:21 +05:30
bo0tzz
4a5f13d0e5 fix: don't skip person thumbnail generation if ML is disabled (#30194) 2026-07-24 10:06:32 -04:00
NOBOIKE
5fa920a5f0 fix(web): use RTL transform origin in AdaptiveImage (#30182) 2026-07-24 10:44:33 +02:00
Jason Rasmussen
acc7e6b299 fix: min faces user preference (#30177) 2026-07-23 17:06:09 -04:00
Adam Gastineau
3af94a4805 fix(mobile): properly align iOS widget loading state (#30173) 2026-07-23 12:31:33 -07:00
11 changed files with 706 additions and 367 deletions

View File

@@ -1,8 +1,8 @@
ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:20ec607c68642c64c73269ce245aa0f060913f4129d15d9687850aa158e6269c AS builder-cpu
FROM python:3.11-bookworm@sha256:5c34b355088846dddc8afb7442c20b9433dccdc8d66192dc52c616adeaa106a3 AS builder-cpu
FROM python:3.13-slim-trixie@sha256:f82c96458eedc847b233e582eb31336f4954b39cae020b6dcf5b3ed0e5cbcd74 AS builder-openvino
FROM python:3.13-slim-trixie@sha256:6771159cd4fa5d9bba1258caf0b82e6b73458c694d178ad97c5e925c2d0e1a91 AS builder-openvino
FROM builder-cpu AS builder-cuda
@@ -39,12 +39,12 @@ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --extra ${DEVICE} --no-dev --no-editable --no-install-project --compile-bytecode --no-progress --active --link-mode copy
FROM python:3.11-slim-bookworm@sha256:e2d3af735aff6eeee600b1933bedd99da6645fedf572cc12ef4cc1331f2ceebe AS prod-cpu
FROM python:3.11-slim-bookworm@sha256:b18992999dbe963a45a8a4da40ac2b1975be1a776d939d098c647482bcad5cba AS prod-cpu
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
MACHINE_LEARNING_MODEL_ARENA=false
FROM python:3.13-slim-trixie@sha256:f82c96458eedc847b233e582eb31336f4954b39cae020b6dcf5b3ed0e5cbcd74 AS prod-openvino
FROM python:3.13-slim-trixie@sha256:6771159cd4fa5d9bba1258caf0b82e6b73458c694d178ad97c5e925c2d0e1a91 AS prod-openvino
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \

725
machine-learning/uv.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,44 +17,67 @@ struct ImmichWidgetView: View {
var entry: ImageEntry
var body: some View {
if entry.image == nil {
VStack {
Image("LaunchImage")
.tintedWidgetImageModifier()
Text(entry.metadata.error?.errorDescription ?? "")
.minimumScaleFactor(0.25)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
}
.padding(16)
if let image = entry.image {
ImmichWidgetContentView(image: image, subtitle: entry.metadata.subtitle, deepLink: entry.metadata.deepLink)
} else {
ZStack(alignment: .leading) {
Color.clear.overlay(
Image(uiImage: entry.image!)
.resizable()
.tintedWidgetImageModifier()
.scaledToFill()
)
VStack {
Spacer()
if let subtitle = entry.metadata.subtitle {
Text(subtitle)
.foregroundColor(.white)
.padding(8)
.background(Color.black.opacity(0.6))
.cornerRadius(8)
.font(.system(size: 16))
}
}
.padding(16)
}
.widgetURL(entry.metadata.deepLink)
ImmichWidgetLoadingView(message: entry.metadata.error?.errorDescription)
}
}
}
private struct ImmichWidgetLoadingView: View {
let message: String?
var body: some View {
let messageText = Text(message ?? "")
.minimumScaleFactor(0.25)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
VStack(spacing: 8) {
// This is used as a nicer way to center the image, rather than using offsets
messageText.hidden()
Image("LaunchImage")
.tintedWidgetImageModifier()
messageText
}
}
}
private struct ImmichWidgetContentView: View {
let image: UIImage
let subtitle: String?
let deepLink: URL?
var body: some View {
ZStack(alignment: .leading) {
Color.clear.overlay(
Image(uiImage: image)
.resizable()
.tintedWidgetImageModifier()
.scaledToFill()
)
VStack {
Spacer()
if let subtitle {
Text(subtitle)
.foregroundColor(.white)
.padding(6)
.background(ContainerRelativeShape().fill(Color.black.opacity(0.6)))
.font(.system(size: 16))
}
}
.padding(16)
}
.widgetURL(deepLink)
}
}
#Preview(
"Medium",
as: .systemMedium,
widget: {
ImmichRandomWidget()
@@ -63,10 +86,93 @@ struct ImmichWidgetView: View {
let date = Date()
ImageEntry(
date: date,
image: UIImage(named: "ImmichLogo"),
image: UIImage(named: "LaunchImage"),
metadata: EntryMetadata(
subtitle: "1 year ago"
)
)
}
)
#Preview(
"Medium No Data",
as: .systemMedium,
widget: {
ImmichRandomWidget()
},
timeline: {
let date = Date()
ImageEntry(
date: date,
image: nil
)
}
)
#Preview(
"Medium No Data Error",
as: .systemMedium,
widget: {
ImmichRandomWidget()
},
timeline: {
let date = Date()
ImageEntry(
date: date,
image: nil,
metadata: EntryMetadata(error: WidgetError.fetchFailed)
)
}
)
#Preview(
"Small",
as: .systemSmall,
widget: {
ImmichRandomWidget()
},
timeline: {
let date = Date()
ImageEntry(
date: date,
image: UIImage(named: "LaunchImage"),
metadata: EntryMetadata(
subtitle: "Yesterday"
)
)
}
)
#Preview(
"Small No Data Error",
as: .systemSmall,
widget: {
ImmichRandomWidget()
},
timeline: {
let date = Date()
ImageEntry(
date: date,
image: nil,
metadata: EntryMetadata(error: WidgetError.fetchFailed)
)
}
)
#Preview(
"Large",
as: .systemLarge,
widget: {
ImmichRandomWidget()
},
timeline: {
let date = Date()
ImageEntry(
date: date,
image: UIImage(named: "LaunchImage"),
metadata: EntryMetadata(
subtitle: "2000 seconds ago"
)
)
}
)

View File

@@ -25,7 +25,7 @@ extension WidgetError: LocalizedError {
return "Login to Immich"
case .fetchFailed:
return "Unable to connect to your Immich instance"
return "Unable to connect to Immich"
case .albumNotFound:
return "Album not found"

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:core';
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
@@ -8,6 +9,7 @@ class ImmichColumnButton extends StatefulWidget {
final IconData icon;
final String label;
final FutureOr<void> Function() onPressed;
final FutureOr<void> Function()? onLongPress;
final bool disabled;
final bool? loading;
@@ -16,6 +18,7 @@ class ImmichColumnButton extends StatefulWidget {
required this.icon,
required this.label,
required this.onPressed,
this.onLongPress,
this.disabled = false,
this.loading,
});
@@ -25,26 +28,44 @@ class ImmichColumnButton extends StatefulWidget {
}
class _ImmichColumnButtonState extends State<ImmichColumnButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
bool _running = false;
bool get _isLoading => widget.loading ?? _running;
bool get _isDisabled => widget.disabled || _isLoading;
Future<void> _onPressed() async {
setState(() => _loading = true);
Future<void> _runAction(FutureOr<void> Function() action) async {
setState(() => _running = true);
try {
await widget.onPressed();
await action();
} finally {
if (mounted) {
setState(() => _loading = false);
setState(() => _running = false);
}
}
}
Future<void>? _onPressed() {
if (_isDisabled) {
return null;
}
return _runAction(widget.onPressed);
}
Future<void>? _onLongPress() {
if (_isDisabled || widget.onLongPress == null) {
return null;
}
return _runAction(widget.onLongPress!);
}
@override
Widget build(BuildContext context) {
final foreground = context.colorOverride ?? Theme.of(context).colorScheme.onSurface;
return TextButton(
onPressed: widget.disabled || _isLoading ? null : _onPressed,
onPressed: _onPressed,
onLongPress: _onLongPress,
style: TextButton.styleFrom(
foregroundColor: foreground,
padding: const .symmetric(horizontal: ImmichSpacing.sm, vertical: ImmichSpacing.md),

View File

@@ -7,6 +7,7 @@ import 'package:immich_ui/src/internal.dart';
class ImmichIconButton extends StatefulWidget {
final IconData icon;
final FutureOr<void> Function() onPressed;
final FutureOr<void> Function()? onLongPress;
final ImmichVariant variant;
final ImmichColor color;
final bool disabled;
@@ -16,6 +17,7 @@ class ImmichIconButton extends StatefulWidget {
super.key,
required this.icon,
required this.onPressed,
this.onLongPress,
this.color = .primary,
this.variant = .filled,
this.disabled = false,
@@ -27,20 +29,37 @@ class ImmichIconButton extends StatefulWidget {
}
class _ImmichIconButtonState extends State<ImmichIconButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
bool _running = false;
bool get _isLoading => widget.loading ?? _running;
bool get _isDisabled => widget.disabled || _isLoading;
Future<void> _onPressed() async {
setState(() => _loading = true);
Future<void> _runAction(FutureOr<void> Function() action) async {
setState(() => _running = true);
try {
await widget.onPressed();
await action();
} finally {
if (mounted) {
setState(() => _loading = false);
setState(() => _running = false);
}
}
}
Future<void>? _onPressed() {
if (_isDisabled) {
return null;
}
return _runAction(widget.onPressed);
}
Future<void>? _onLongPress() {
if (_isDisabled || widget.onLongPress == null) {
return null;
}
return _runAction(widget.onLongPress!);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
@@ -73,7 +92,8 @@ class _ImmichIconButtonState extends State<ImmichIconButton> {
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.md),
)
: Icon(widget.icon),
onPressed: widget.disabled || _isLoading ? null : _onPressed,
onPressed: _onPressed,
onLongPress: _onLongPress,
style: IconButton.styleFrom(backgroundColor: background, foregroundColor: foreground),
);
}

View File

@@ -7,6 +7,7 @@ class ImmichTextButton extends StatefulWidget {
final String labelText;
final IconData? icon;
final FutureOr<void> Function() onPressed;
final FutureOr<void> Function()? onLongPress;
final ImmichVariant variant;
final bool expanded;
final bool disabled;
@@ -17,6 +18,7 @@ class ImmichTextButton extends StatefulWidget {
required this.labelText,
this.icon,
required this.onPressed,
this.onLongPress,
this.variant = .filled,
this.expanded = true,
@@ -29,20 +31,37 @@ class ImmichTextButton extends StatefulWidget {
}
class _ImmichTextButtonState extends State<ImmichTextButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
bool _running = false;
bool get _isLoading => widget.loading ?? _running;
bool get _isDisabled => widget.disabled || _isLoading;
Future<void> _onPressed() async {
setState(() => _loading = true);
Future<void> _runAction(FutureOr<void> Function() action) async {
setState(() => _running = true);
try {
await widget.onPressed();
await action();
} finally {
if (mounted) {
setState(() => _loading = false);
setState(() => _running = false);
}
}
}
Future<void>? _onPressed() {
if (_isDisabled) {
return null;
}
return _runAction(widget.onPressed);
}
Future<void>? _onLongPress() {
if (_isDisabled || widget.onLongPress == null) {
return null;
}
return _runAction(widget.onLongPress!);
}
@override
Widget build(BuildContext context) {
final Widget? icon = _isLoading
@@ -59,11 +78,22 @@ class _ImmichTextButtonState extends State<ImmichTextButton> {
style: const .new(fontSize: ImmichTextSize.body, fontWeight: .bold),
);
final style = ElevatedButton.styleFrom(padding: const .symmetric(vertical: ImmichSpacing.md));
final onPressed = widget.disabled || _isLoading ? null : _onPressed;
final button = switch (widget.variant) {
ImmichVariant.filled => ElevatedButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
ImmichVariant.ghost => TextButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
ImmichVariant.filled => ElevatedButton.icon(
style: style,
onPressed: _onPressed,
onLongPress: _onLongPress,
icon: icon,
label: label,
),
ImmichVariant.ghost => TextButton.icon(
style: style,
onPressed: _onPressed,
onLongPress: _onLongPress,
icon: icon,
label: label,
),
};
if (widget.expanded) {

View File

@@ -0,0 +1,25 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`
INSERT INTO "user_metadata" ("userId", "key", "value")
SELECT "user"."id", 'preferences', jsonb_build_object('people', jsonb_build_object('minimumFaces', "config"."minFaces"))
FROM "user"
CROSS JOIN (
SELECT "value"->'machineLearning'->'facialRecognition'->'minFaces' AS "minFaces"
FROM "system_metadata"
WHERE "key" = 'system-config'
AND "value"->'machineLearning'->'facialRecognition'->'minFaces' IS NOT NULL
) AS "config"
ON CONFLICT ("userId", "key") DO UPDATE
SET "value" = "user_metadata"."value" || jsonb_build_object(
'people',
COALESCE("user_metadata"."value"->'people', '{}'::jsonb) || (EXCLUDED."value"->'people')
)
WHERE "user_metadata"."value"->'people'->'minimumFaces' IS NULL
`.execute(db);
}
export async function down(): Promise<void> {
// not supported
}

View File

@@ -1507,12 +1507,17 @@ describe(MediaService.name, () => {
});
describe('handleGeneratePersonThumbnail', () => {
it('should skip if machine learning is disabled', async () => {
it('should generate a thumbnail even if machine learning is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled);
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle);
mocks.media.generateThumbnail.mockResolvedValue();
mocks.media.decodeImage.mockResolvedValue({
data: Buffer.from(''),
info: { width: 1000, height: 1000 } as OutputInfo,
});
await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(JobStatus.Skipped);
expect(mocks.asset.getByIds).not.toHaveBeenCalled();
expect(mocks.systemMetadata.get).toHaveBeenCalled();
await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(JobStatus.Success);
expect(mocks.media.generateThumbnail).toHaveBeenCalled();
});
it('should skip a person not found', async () => {

View File

@@ -43,7 +43,7 @@ import { getAssetFile, getDimensions } from 'src/utils/asset.util';
import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor';
import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
import { mimeTypes } from 'src/utils/mime-types';
import { clamp, isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc';
import { clamp } from 'src/utils/misc';
import { getOutputDimensions } from 'src/utils/transform';
interface UpsertFileOptions {
@@ -410,11 +410,7 @@ export class MediaService extends BaseService {
@OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration })
async handleGeneratePersonThumbnail({ id }: JobOf<JobName.PersonGenerateThumbnail>): Promise<JobStatus> {
const { machineLearning, metadata, image } = await this.getConfig({ withCache: true });
if (!isFacialRecognitionEnabled(machineLearning) && !isFaceImportEnabled(metadata)) {
return JobStatus.Skipped;
}
const { image } = await this.getConfig({ withCache: true });
const data = await this.personRepository.getDataForThumbnailGenerationJob(id);
if (!data) {
this.logger.error(`Could not generate person thumbnail for ${id}: missing data`);

View File

@@ -63,6 +63,7 @@
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
import { untrack, type Snippet } from 'svelte';
import { languageManager } from '$lib/managers/language-manager.svelte';
type Props = {
asset: AssetResponseDto;
@@ -232,7 +233,7 @@
style:width={rasterWidth}
style:height={rasterHeight}
style:transform="scale({rasterScale})"
style:transform-origin="0 0"
style:transform-origin={languageManager.rtl ? 'right top' : 'left top'}
style:will-change={maxRasterPixels > 0 ? 'transform' : undefined}
>
{#if show.alphaBackground}