diff --git a/README.md b/README.md
index 7e06d9de4b..13c4009b68 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,7 @@
العربية
Tiếng Việt
ภาษาไทย
+ മലയാളം
diff --git a/docs/docs/administration/backup-and-restore.md b/docs/docs/administration/backup-and-restore.md
index debbad7575..df34671a1b 100644
--- a/docs/docs/administration/backup-and-restore.md
+++ b/docs/docs/administration/backup-and-restore.md
@@ -31,7 +31,7 @@ You can trigger a database backup manually:
1. Go to **Administration > Job Queues**
2. Click **Create job** in the top right
-3. Select **Create Database Backup** and click **Confirm**
+3. Select **Create Database Dump** and click **Confirm**
The backup will appear in `UPLOAD_LOCATION/backups` and counts toward your retention limit.
diff --git a/docs/docs/install/upgrading.md b/docs/docs/install/upgrading.md
index 8fc9113e6d..f302719c76 100644
--- a/docs/docs/install/upgrading.md
+++ b/docs/docs/install/upgrading.md
@@ -28,11 +28,15 @@ docker image prune
## Versioning Policy
-Immich follows [semantic versioning][semver], which tags releases in the format `..`. We intend for breaking changes to be limited to major version releases.
-You can configure your Docker image to point to the current major version by using a metatag, such as `:v3`.
+Immich follows [semantic versioning][semver], which tags releases in the format `..`.
+We intend for breaking changes, including those to the API or deployment, to be limited to major version releases.
+You can configure your Docker image to point to the current major version by using a metatag, such as `:v3`. These metatags do not follow release candidates.
-Currently, we have no plans to backport patches to earlier versions. We encourage all users to run the most recent release of Immich.
-Switching back to an earlier version, even within the same minor release tag, is not supported.
+The mobile app is typically compatible with the current and prior major version. However, the server is only compatible with the matching major version.
+Thus, we recommend upgrading all mobile clients before upgrading the server to ensure compatibility.
+
+We do not backport patches to earlier versions. We encourage all users to run the most recent stable release of Immich.
+Downgrading to an earlier version, even within the same minor version, is not supported.
[semver]: https://semver.org/
diff --git a/i18n/en.json b/i18n/en.json
index 743b269b28..5f8509b4c7 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -431,6 +431,10 @@
"transcoding_realtime_description": "Allows transcoding to be performed in real-time as the video is being streamed. Enables quality switching, but may cause higher playback latency and stuttering depending on server capabilities.",
"transcoding_realtime_enabled": "Enable real-time transcoding",
"transcoding_realtime_enabled_description": "If disabled, the server will refuse to start new real-time transcoding sessions.",
+ "transcoding_realtime_resolutions": "Resolutions",
+ "transcoding_realtime_resolutions_description": "The resolutions offered for real-time transcoding. Higher resolutions may cause playback issues if the server cannot transcode them quickly enough.",
+ "transcoding_realtime_video_codecs": "Video codecs",
+ "transcoding_realtime_video_codecs_description": "The video codecs offered for real-time transcoding. Clients will choose the best option they support during playback. AV1 is more efficient than HEVC, which is more efficient than H.264. When using hardware acceleration, only select the codecs the accelerator can encode. When using software transcoding, note that H.264 is faster than AV1, which is faster than HEVC.",
"transcoding_reference_frames": "Reference frames",
"transcoding_reference_frames_description": "The number of frames to reference when compressing a given frame. Higher values improve compression efficiency, but slow down encoding. 0 sets this value automatically.",
"transcoding_required_description": "Only videos not in an accepted format",
@@ -593,6 +597,7 @@
"asset_viewer_settings_title": "Asset Viewer",
"assets": "Assets",
"assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album",
+ "assets_added_to_album_partial_count": "Added {successCount} out of {totalCount} {totalCount, plural, one {asset} other {assets}} to the album",
"assets_added_to_albums_count": "Added {assetTotal, plural, one {# asset} other {# assets}} to {albumTotal, plural, one {# album} other {# albums}}",
"assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album",
"assets_cannot_be_added_to_albums": "{count, plural, one {Asset} other {Assets}} cannot be added to any of the albums",
diff --git a/mobile/android/app/src/main/cpp/native_image.c b/mobile/android/app/src/main/cpp/native_image.c
index 07bb7e9a2c..3a4f2f11a4 100644
--- a/mobile/android/app/src/main/cpp/native_image.c
+++ b/mobile/android/app/src/main/cpp/native_image.c
@@ -107,3 +107,67 @@ Java_app_alextran_immich_NativeImage_rotate(
}
return (jlong) dst;
}
+
+// Convert an RGBA_1010102 buffer to densely-packed RGBA_8888, matching Skia's
+// Bitmap.copy(ARGB_8888) byte-for-byte so it's a drop-in for the intermediate 8888 bitmap.
+// Each source pixel is a native (little-endian on every Android ABI) u32 with R in bits 0-9,
+// G in 10-19, B in 20-29, A in 30-31 (standard RGB10_A2). Each 10-bit channel maps to 8-bit via
+// round(v*255/1023). The 2-bit alpha maps to a*85. Both are kept as plain arithmetic as the whole
+// loop auto-vectorizes to NEON and measures faster than a LUT on-device. Output is R,G,B,A bytes
+// per pixel, i.e. Android ARGB_8888 memory == Dart PixelFormat.rgba8888.
+static void convert_1010102(const uint8_t *src, int srcStride, uint32_t *dst, int w, int h) {
+ for (int y = 0; y < h; y++) {
+ const uint32_t *srcRow = (const uint32_t *) (src + (size_t) y * srcStride);
+ uint32_t *dstRow = dst + (size_t) y * w;
+ for (int x = 0; x < w; x++) {
+ uint32_t px = srcRow[x];
+ uint32_t r = ((px & 0x3FF) * 16336u + 32768u) >> 16;
+ uint32_t g = (((px >> 10) & 0x3FF) * 16336u + 32768u) >> 16;
+ uint32_t b = (((px >> 20) & 0x3FF) * 16336u + 32768u) >> 16;
+ uint32_t a = ((px >> 30) & 0x3) * 85u;
+ dstRow[x] = r | (g << 8) | (b << 16) | (a << 24);
+ }
+ }
+}
+
+// Converts an RGBA_1010102 bitmap (what a 10-bit HEIC/AVIF decodes to on API 33+) into a freshly
+// malloc'd RGBA_8888 buffer. Fills outInfo with {width, height, rowBytes} and returns the buffer
+// address, or 0 (so the caller falls back to a Skia copy) if the bitmap isn't 1010102 or can't be
+// locked. Same ownership contract as rotate: free the returned buffer via NativeBuffer.free.
+JNIEXPORT jlong JNICALL
+Java_app_alextran_immich_NativeImage_convert1010102(
+ JNIEnv *env, jclass clazz, jobject bitmap, jintArray outInfo) {
+ AndroidBitmapInfo info;
+ if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
+ return 0;
+ }
+ if (info.format != ANDROID_BITMAP_FORMAT_RGBA_1010102) {
+ return 0;
+ }
+
+ int w = (int) info.width;
+ int h = (int) info.height;
+
+ uint32_t *dst = (uint32_t *) malloc((size_t) w * h * 4);
+ if (dst == NULL) {
+ return 0;
+ }
+
+ void *srcPixels = NULL;
+ if (AndroidBitmap_lockPixels(env, bitmap, &srcPixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
+ free(dst);
+ return 0;
+ }
+
+ convert_1010102((const uint8_t *) srcPixels, (int) info.stride, dst, w, h);
+
+ AndroidBitmap_unlockPixels(env, bitmap);
+
+ jint dims[3] = {w, h, w * 4};
+ (*env)->SetIntArrayRegion(env, outInfo, 0, 3, dims);
+ if ((*env)->ExceptionCheck(env)) {
+ free(dst);
+ return 0;
+ }
+ return (jlong) dst;
+}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeImage.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeImage.kt
index b1398d84b8..68feaa2d14 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeImage.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeImage.kt
@@ -16,4 +16,14 @@ object NativeImage {
*/
@JvmStatic
external fun rotate(bitmap: Bitmap, orientation: Int, outInfo: IntArray): Long
+
+ /**
+ * Converts an RGBA_1010102 [bitmap] (what a 10-bit HEIC/AVIF decodes to on API 33+) to RGBA_8888,
+ * writing the result into a freshly malloc'd native buffer in one pass, with no intermediate
+ * ARGB_8888 bitmap. Returns the buffer address (free it with [NativeBuffer.free]) and fills
+ * [outInfo] with {width, height, rowBytes}. Returns 0 when the bitmap isn't RGBA_1010102 so the
+ * caller can fall back to a Skia copy.
+ */
+ @JvmStatic
+ external fun convert1010102(bitmap: Bitmap, outInfo: IntArray): Long
}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt
index 6d2499ee0f..2f5db1ca0e 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt
@@ -46,22 +46,47 @@ inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0)): Bitmap {
}
fun Bitmap.toNativeBuffer(): Map {
- val size = width * height * 4
+ // Dart reads the buffer as rgba8888, but 10-bit sources decode to RGBA_1010102, which garbles
+ // colors when copied verbatim. Convert those straight into the output buffer in native code -
+ // one pass, no intermediate ARGB_8888 bitmap.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && config == Bitmap.Config.RGBA_1010102) {
+ val info = IntArray(3)
+ val pointer = NativeImage.convert1010102(this, info)
+ if (pointer != 0L) {
+ recycle()
+ return mapOf(
+ "pointer" to pointer,
+ "width" to info[0].toLong(),
+ "height" to info[1].toLong(),
+ "rowBytes" to info[2].toLong()
+ )
+ }
+ // native convert declined (OOM/lock) -> fall through to the Skia copy path below.
+ }
+ // Other non-8888 configs (e.g. HDR F16) still need Skia's convert; 8-bit is copied as-is.
+ val bitmap = if (config != Bitmap.Config.ARGB_8888) {
+ val converted = copy(Bitmap.Config.ARGB_8888, false)
+ recycle()
+ converted ?: throw IOException("could not convert bitmap to ARGB_8888")
+ } else {
+ this
+ }
+ val size = bitmap.width * bitmap.height * 4
val pointer = NativeBuffer.allocate(size)
try {
val buffer = NativeBuffer.wrap(pointer, size)
- copyPixelsToBuffer(buffer)
+ bitmap.copyPixelsToBuffer(buffer)
return mapOf(
"pointer" to pointer,
- "width" to width.toLong(),
- "height" to height.toLong(),
- "rowBytes" to (width * 4).toLong()
+ "width" to bitmap.width.toLong(),
+ "height" to bitmap.height.toLong(),
+ "rowBytes" to (bitmap.width * 4).toLong()
)
- } catch (e: Exception) {
+ } catch (e: Throwable) {
NativeBuffer.free(pointer)
throw e
} finally {
- recycle()
+ bitmap.recycle()
}
}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt
index f7ebc349f6..226305dd85 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt
@@ -1,6 +1,8 @@
package app.alextran.immich.images
import android.content.Context
+import android.graphics.ImageDecoder
+import android.os.Build
import android.os.CancellationSignal
import android.os.OperationCanceledException
import app.alextran.immich.INITIAL_BUFFER_SIZE
@@ -22,6 +24,7 @@ import java.io.File
import java.io.IOException
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.Executors
private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024
@@ -36,12 +39,16 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
companion object {
val CANCELLED = Result.success