From db411a4497ec3fa29614a15723bb8e62dc31652d Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:59:26 -0400 Subject: [PATCH] feat(server): add jxl as target format (#32732) (cherry picked from commit 9560989cb97fb93cd6ab74f7811acce133a9b768) --- i18n/en.json | 4 +- .../immich_ml/models/transforms.py | 4 ++ machine-learning/pyproject.toml | 1 + machine-learning/uv.lock | 50 +++++++++++++++ open-api/immich-openapi-specs.json | 1 + packages/sdk/src/fetch-client.ts | 1 + server/src/constants.ts | 3 + server/src/enum.ts | 1 + .../src/repositories/media.repository.spec.ts | 8 ++- server/src/repositories/media.repository.ts | 1 + server/src/services/media.service.spec.ts | 63 +++++++++++++++++++ server/src/services/media.service.ts | 28 ++++++--- server/src/types.ts | 15 ++--- server/src/utils/profile-image.ts | 2 + .../system-settings/ImageSettings.svelte | 15 +++-- 15 files changed, 167 insertions(+), 30 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 8e5840a55..3f5927ee5 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -87,7 +87,7 @@ "failed_job_command": "Command {command} failed for job: {job}", "force_delete_user_warning": "WARNING: This will immediately remove the user and all assets. This cannot be undone and the files cannot be recovered.", "image_format": "Format", - "image_format_description": "WebP produces smaller files than JPEG, but is slower to encode.", + "image_format_description": "WebP produces smaller files than JPEG, but is slower to encode. JPEG XL is the best option for high-quality compression, but not all browsers and apps can display it.", "image_fullsize_description": "Full-size image with stripped metadata, used when zoomed in", "image_fullsize_enabled": "Enable full-size image generation", "image_fullsize_enabled_description": "Generate full-size image for non-web-friendly formats. When \"Prefer embedded preview\" is enabled, embedded previews are used directly without conversion. Does not affect web-friendly formats like JPEG.", @@ -101,7 +101,7 @@ "image_preview_quality_description": "Preview quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness. Setting a low value may affect machine learning quality.", "image_preview_title": "Preview Settings", "image_progressive": "Progressive", - "image_progressive_description": "Encode JPEG images progressively for gradual loading display. This has no effect on WebP images.", + "image_progressive_description": "Encode JPEG images progressively for gradual loading display. This has no effect on other formats.", "image_quality": "Quality", "image_resolution": "Resolution", "image_resolution_description": "Higher resolutions can preserve more detail but take longer to encode, have larger file sizes and can reduce app responsiveness.", diff --git a/machine-learning/immich_ml/models/transforms.py b/machine-learning/immich_ml/models/transforms.py index debaf03c6..751be7eb0 100644 --- a/machine-learning/immich_ml/models/transforms.py +++ b/machine-learning/immich_ml/models/transforms.py @@ -7,10 +7,14 @@ import numpy as np import orjson from numpy.typing import NDArray from PIL import Image +from pillow_jxl import JpegXLImagePlugin _PIL_RESAMPLING_METHODS = {resampling.name.lower(): resampling for resampling in Image.Resampling} _PUNCTUATION_TRANS = str.maketrans("", "", string.punctuation) +# Requests are already decoded in parallel, so don't start a thread per core for each one +JpegXLImagePlugin.DECODE_THREADS = 0 + def resize_pil(img: Image.Image, size: int, resample: Image.Resampling = Image.Resampling.BICUBIC) -> Image.Image: if img.width < img.height: diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 9ea191da3..fe5da3f73 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "opencv-python-headless>=4.7.0.72,<5.0", "orjson>=3.9.5", "pillow>=13,<14", + "pillow-jxl-plugin>=1.3.8,<2.0", "pydantic>=2.0.0,<3", "pydantic-settings>=2.5.2,<3", "python-multipart>=0.0.6,<1.0", diff --git a/machine-learning/uv.lock b/machine-learning/uv.lock index 6693551a5..c3acae07e 100644 --- a/machine-learning/uv.lock +++ b/machine-learning/uv.lock @@ -842,6 +842,7 @@ dependencies = [ { name = "opencv-python-headless" }, { name = "orjson" }, { name = "pillow" }, + { name = "pillow-jxl-plugin" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "python-multipart" }, @@ -936,6 +937,7 @@ requires-dist = [ { name = "opencv-python-headless", specifier = ">=4.7.0.72,<5.0" }, { name = "orjson", specifier = ">=3.9.5" }, { name = "pillow", specifier = ">=13,<14" }, + { name = "pillow-jxl-plugin", specifier = ">=1.3.8,<2.0" }, { name = "pydantic", specifier = ">=2.0.0,<3" }, { name = "pydantic-settings", specifier = ">=2.5.2,<3" }, { name = "python-multipart", specifier = ">=0.0.6,<1.0" }, @@ -1802,6 +1804,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/37/66b12dc476041fe523bca29c9d58fbf1ea92a4eaf73acfb9e993948f1191/pillow-13.0.0-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:567f20f1c75897d7663830da6ea8af719e03d712b1d9ee7f813319f9ece42e5a", size = 7366030, upload-time = "2026-10-16T22:18:06.638Z" }, ] +[[package]] +name = "pillow-jxl-plugin" +version = "1.3.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/75/810608d489c2d453e16a0866de5edea2bedd728e8830da22a1ec633f3b80/pillow_jxl_plugin-1.3.8.tar.gz", hash = "sha256:4430fd7757899742079c548a7d2537d6d63cf0f1d323180095bc0f6f03d9d4fa", size = 1602233, upload-time = "2026-07-17T03:23:50.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/bb/093aced52ebaafcd050eb21ab07ddcab7efffaa687e264f64d3f0423a4ba/pillow_jxl_plugin-1.3.8-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7f6fd0fa12dd0d98bb55df6bbfff7ad913e4ea27306fbdf5e8543fe1fa40772c", size = 2361426, upload-time = "2026-07-17T03:22:56.906Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9f/f272fd205ab511475b135772e85bcaa71e5f3d081e6604b08e6e1d3982a6/pillow_jxl_plugin-1.3.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01b632dbca07b59f7d6e106dad3ac8391e9ffdab1f94f2068a3e8edff1bbd180", size = 2660368, upload-time = "2026-07-17T03:22:48.663Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/3445d2b4f6d6e4e26f10e9ccd75b60a14295b2d8eb280f0b1bd0bd7caac3/pillow_jxl_plugin-1.3.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6459d131af2cbccab20742f6fe43a3022b021dcd1ee20b05f99b1fb54d0ec525", size = 2788244, upload-time = "2026-07-17T03:22:21.809Z" }, + { url = "https://files.pythonhosted.org/packages/26/f2/3855e83fd1e4043bd7ab286d3f0ca434fbf935598fae57f1af2777620891/pillow_jxl_plugin-1.3.8-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b9a546ac71757f53f0ca2b14ea8f99c33b70e298a7873477f4548e2d83f817", size = 2500550, upload-time = "2026-07-17T03:22:30.993Z" }, + { url = "https://files.pythonhosted.org/packages/f0/13/d9befae0e705474e550b16c90bb6d33c3f07837af63a0e8ce4c84fb546fb/pillow_jxl_plugin-1.3.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd2afd5153e2a7ef230a3e48a679a9e1cb3e59032f79c78bb2f78010a4e9bfc1", size = 2504336, upload-time = "2026-07-17T03:22:40.143Z" }, + { url = "https://files.pythonhosted.org/packages/52/2c/3e671c8dd84f6ed4e47d588df1dc15aae61b0df0302f4bfff7b5c743045b/pillow_jxl_plugin-1.3.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b987fb202dd1c71d7f6e103363afd1128d347ffcf540d8416c257c8f1041ff3", size = 9727025, upload-time = "2026-07-17T03:23:06.554Z" }, + { url = "https://files.pythonhosted.org/packages/27/89/a63b75d9c05deb0017ff51ddf3674cef19dbfe9c2fb74117d7e4a66b9a93/pillow_jxl_plugin-1.3.8-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c05cf62a992f48434a6b876fe010e5cae12bd54039854ce9ae0f05c13a58c7ad", size = 8386443, upload-time = "2026-07-17T03:23:19.195Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4f/b6ead8f5a50103e2c6b264a6d4778b8fcd08006c957b2ad0bccbbb306e97/pillow_jxl_plugin-1.3.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0392e5349b67522130ecff9d8dca4713e4c84662a310d2170291b229965e8fd1", size = 9032022, upload-time = "2026-07-17T03:23:30.993Z" }, + { url = "https://files.pythonhosted.org/packages/ea/53/b9aa7c156427ad4d94d8c90bd87cb502e7d6326d2689c309aa574baf1ab7/pillow_jxl_plugin-1.3.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:88b06a4e5fc0d882b188824990b6571e201ad775d22a09487c8d5ab8aed9c3b7", size = 9233286, upload-time = "2026-07-17T03:23:42.022Z" }, + { url = "https://files.pythonhosted.org/packages/32/e3/b25194909cfef9081f6a1f0e40e699e95ea96b211cae30bbf0f965d6b450/pillow_jxl_plugin-1.3.8-cp312-cp312-win32.whl", hash = "sha256:3345a45a09ba3149001c3e9a1b6991be8aab72c33097c0633e414a254cd283fa", size = 4552597, upload-time = "2026-07-17T03:24:08.129Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c9/01efd23cb4827f8a1731e1a7652023a06375e933f85e3169e0761d916d6f/pillow_jxl_plugin-1.3.8-cp312-cp312-win_amd64.whl", hash = "sha256:5b1c0d7a0c7e451014b288f0504545bff0f543f2ee1581af628e01e885b75f50", size = 4937252, upload-time = "2026-07-17T03:24:00.294Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/b3aee5725df66399f529c41d97f71893e2b38f665c9278a99519112f03f3/pillow_jxl_plugin-1.3.8-cp312-cp312-win_arm64.whl", hash = "sha256:230a1d086ef5b66055e0dbc9837914becf1d1ca58d45d1404660de0a11fb63c3", size = 2330507, upload-time = "2026-07-17T03:23:53.22Z" }, + { url = "https://files.pythonhosted.org/packages/b9/10/18bb2d6ed82184e2427f7501a17c1b413eb50bdcd4ebb42bbfb187bb4290/pillow_jxl_plugin-1.3.8-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:fe24e957016dcb0c2ec35f72ff3bb8ae78a9468b282f0d423485556de492975c", size = 2361184, upload-time = "2026-07-17T03:22:58.376Z" }, + { url = "https://files.pythonhosted.org/packages/89/3e/5e1156e5c5b402d84ef924153e046ba937ac46e8b14decb3ab6e01a6f40e/pillow_jxl_plugin-1.3.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d648ef965002bee0c98dbf144b9b3c994146856fa4d3c54008b75dda10f69915", size = 2659163, upload-time = "2026-07-17T03:22:50.003Z" }, + { url = "https://files.pythonhosted.org/packages/e9/fe/e610196142343f80c98502fd204f28e901a1101db6988be6ccc40c0e8ca0/pillow_jxl_plugin-1.3.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34685b27d01e55aabb09d9436d4ddb1f446e8805da82ffd6448c07bbf563354c", size = 2787568, upload-time = "2026-07-17T03:22:23.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/18/a862b140f82be55ce62c681b63c38bf61c962f0c27e32797ebebc0d47548/pillow_jxl_plugin-1.3.8-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:288eb07090c91891ee0dc782786d937af8e53a117835606d7982c29e31072b90", size = 2499957, upload-time = "2026-07-17T03:22:32.414Z" }, + { url = "https://files.pythonhosted.org/packages/59/29/d430cd5f82c08d2950e292d195081e9dd4e2a038a7e1722d79ba7c6e7ed9/pillow_jxl_plugin-1.3.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b39ac44cbe5a98b8a364ed98a16c8c1e197ee997e32731be4be727c55e629e4", size = 2504367, upload-time = "2026-07-17T03:22:41.501Z" }, + { url = "https://files.pythonhosted.org/packages/f4/68/6806df96fdd625943c795beca932ac033c255a323b63b051903df96935b6/pillow_jxl_plugin-1.3.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0e1550f30768a0aa3f756da10b7807f6fac7fd2e5a18991eb8e00ec5908089d8", size = 9726010, upload-time = "2026-07-17T03:23:08.579Z" }, + { url = "https://files.pythonhosted.org/packages/12/df/d12f42d6e31bb47e2002fcba2db9d92aad19dc3db9ae7d4b2b76152e268a/pillow_jxl_plugin-1.3.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b13f860c280d9d5770f49a1909926f15c6e26e50935da52ecb78700f7c377857", size = 8386305, upload-time = "2026-07-17T03:23:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/92/ee/4dc11ad5a90aa6d2eeae6eb0be2376cde0c365423036a078746b3476eca2/pillow_jxl_plugin-1.3.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:273908ffb85f68a2374b0934700939c572f32b355c9d128d4e3b6924e72ef274", size = 9031514, upload-time = "2026-07-17T03:23:32.948Z" }, + { url = "https://files.pythonhosted.org/packages/ae/76/b3125f13c9551862ad3ae7137d86959cde159b759083f3e7b5e720525fd0/pillow_jxl_plugin-1.3.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2be721646718af722f2ca5c1d6b06bf056a61a0db355cf944df53c3723119d4b", size = 9233009, upload-time = "2026-07-17T03:23:44.276Z" }, + { url = "https://files.pythonhosted.org/packages/13/16/1aa669ae22a6c36810a925bbbfb1f4521df1d0402180052331b39ef8dee0/pillow_jxl_plugin-1.3.8-cp313-cp313-win32.whl", hash = "sha256:a2716f1367ed9647da36ce15882a12b4d424892cb5a03ab4b0783039d701c7c2", size = 4551381, upload-time = "2026-07-17T03:24:09.552Z" }, + { url = "https://files.pythonhosted.org/packages/0e/e7/823f58aaea0d969f876f593a39c85c39fefe1b8ee9ca10a6ffbafe1a254a/pillow_jxl_plugin-1.3.8-cp313-cp313-win_amd64.whl", hash = "sha256:2388374b3493df9add4ef3d97cc70d6a5f51ed188dfe10d2b83ebc55e82e48d4", size = 4936720, upload-time = "2026-07-17T03:24:01.9Z" }, + { url = "https://files.pythonhosted.org/packages/3e/12/e04b73832d1b8af3c5c4646127949a362f9ca3d30f42b05156ca5ffc61ea/pillow_jxl_plugin-1.3.8-cp313-cp313-win_arm64.whl", hash = "sha256:48d13d81d1c4933e5d42dd09366a9827583808be0a2fe842abd7358653c7d92f", size = 2330087, upload-time = "2026-07-17T03:23:54.604Z" }, + { url = "https://files.pythonhosted.org/packages/66/30/c37d161345302ebda07f29d8230b15ba83b43c586f14fec98d088b18e982/pillow_jxl_plugin-1.3.8-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:30b72accb8505a46b05f709871da40f09975aea3a6b6f3cb1d646ff0a9aaba5e", size = 2362140, upload-time = "2026-07-17T03:22:59.694Z" }, + { url = "https://files.pythonhosted.org/packages/a7/be/01613af0307c31e77aced40bdf9cf3d25ce1333b3e99bb85575bdcbfeca5/pillow_jxl_plugin-1.3.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b5bbc933487207e634802f21990c365b6e61473cea34f6621420f4eda8ffd1df", size = 2660381, upload-time = "2026-07-17T03:22:51.351Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8b/89636d315a31eb805681e60e569fe38d5eef3d09f13b1b6f576574387942/pillow_jxl_plugin-1.3.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1d06fd4eb038f431db27f1ab2e483fd6967bafb21a7757e1bd5f76b6fd93649", size = 2787875, upload-time = "2026-07-17T03:22:24.736Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e0/7b9bdc388d89773a5f9ae74ff0be3b06080ef1a60be57b5897d929012622/pillow_jxl_plugin-1.3.8-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f65361ce18db59e8949c60599bfa46b7de187e28917fc39b5d816ab4b81cb16d", size = 2500834, upload-time = "2026-07-17T03:22:33.928Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/0903ba5a4c8d948df9217e48182ff7d872098ddf86fe512402569f4041cc/pillow_jxl_plugin-1.3.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b881a40737c647a24270b73cd3f506dd16d634202e5214cb492f9270bd1b02", size = 2504805, upload-time = "2026-07-17T03:22:42.911Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ef/77a15b2f3227854d4ef2563250ea57b03538ed962f406f1248c9caf045aa/pillow_jxl_plugin-1.3.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4049e48f09947a38047fc5d70817c6dffa90ffb5f14585c8c00e8b534d17c9b", size = 9725768, upload-time = "2026-07-17T03:23:10.845Z" }, + { url = "https://files.pythonhosted.org/packages/c6/95/486fc1fabd1e6cb8b520dce9898b51f2e29e991ba54e632381fe7f287300/pillow_jxl_plugin-1.3.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6ce20ef0887efb6dace5245f714f94b4f070ef39a1ec45161b35879c48faf30a", size = 8386447, upload-time = "2026-07-17T03:23:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/05/28/7979927eb5dc79e40b31e4e07d074057722656743d324182f4a68056c2a9/pillow_jxl_plugin-1.3.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:845e61add39272ca8545f4a0e6ead977ebb7e89fa017014bb8e17273a7a09c6c", size = 9032664, upload-time = "2026-07-17T03:23:34.78Z" }, + { url = "https://files.pythonhosted.org/packages/92/b7/f606ed6fcb5fd9e177ce2b15a613a405ac22ceafc2c03bb3f58cd500ca04/pillow_jxl_plugin-1.3.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:915b8fee6a97aaf5486d0d625f26faae2e9f0d9b5892f1076f5a29b8103125a4", size = 9233868, upload-time = "2026-07-17T03:23:46.277Z" }, + { url = "https://files.pythonhosted.org/packages/a4/6d/d10a4426fc3b394d2222cb694774451e67ab052df2e15a488dfac22be561/pillow_jxl_plugin-1.3.8-cp314-cp314-win32.whl", hash = "sha256:d63d1c3566a8c9ebdd1ec6b75da816b9475d95c4f69656827d6d3957d2594ecb", size = 4554691, upload-time = "2026-07-17T03:24:11.39Z" }, + { url = "https://files.pythonhosted.org/packages/47/77/0f0297c265f1743784b6d1df719e62e49dabd1d9ede8efb0a9ba93174293/pillow_jxl_plugin-1.3.8-cp314-cp314-win_amd64.whl", hash = "sha256:6b23c83aabf4615b92ae609293d8597ca1a8d14d01ab16c703bb3a3df359796b", size = 4939714, upload-time = "2026-07-17T03:24:03.288Z" }, + { url = "https://files.pythonhosted.org/packages/ca/cf/2db398fb1a41c46fd382bad96ee90283589eaed0ec2c50cfe4afda3df72c/pillow_jxl_plugin-1.3.8-cp314-cp314-win_arm64.whl", hash = "sha256:60c36bec3d8d4ef23cf2aed0f792213f999312dd3d8cd5225ae1494c430e16f6", size = 2330667, upload-time = "2026-07-17T03:23:55.913Z" }, +] + [[package]] name = "pluggy" version = "1.5.0" diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 00ee30781..ba21d45ec 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -23133,6 +23133,7 @@ "description": "Image format", "enum": [ "jpeg", + "jxl", "webp" ], "type": "string" diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index f9cb6930c..5ffe87035 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -8214,6 +8214,7 @@ export enum Colorspace { } export enum ImageFormat { Jpeg = "jpeg", + Jxl = "jxl", Webp = "webp" } export enum LogLevel { diff --git a/server/src/constants.ts b/server/src/constants.ts index 13f64db34..a0b19d754 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -64,6 +64,9 @@ export const excludePaths = ['/.well-known/immich', '/.well-known/webauthn', '/c export const FACE_THUMBNAIL_SIZE = 250; +// The fastest JPEG XL tier skips smoothing filters, which also keeps small thumbnails sharper +export const THUMBNAIL_DECODING_TIER = 4; + type ModelInfo = { dimSize: number }; export const CLIP_MODEL_INFO: Record = { RN101__openai: { dimSize: 512 }, diff --git a/server/src/enum.ts b/server/src/enum.ts index 5208eac40..0d724ce7b 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -610,6 +610,7 @@ export const ColorspaceSchema = z.enum(Colorspace).describe('Colorspace').meta({ export enum ImageFormat { Jpeg = 'jpeg', + Jxl = 'jxl', Webp = 'webp', } diff --git a/server/src/repositories/media.repository.spec.ts b/server/src/repositories/media.repository.spec.ts index fad6201d1..02f413b3f 100644 --- a/server/src/repositories/media.repository.spec.ts +++ b/server/src/repositories/media.repository.spec.ts @@ -331,7 +331,13 @@ describe(MediaRepository.name, () => { const decoded = await sut.decodeImage(buffer, { colorspace: Colorspace.P3, processInvalidImages: false }); await sut.generateThumbnail( decoded, - { colorspace: Colorspace.P3, quality: 80, format: ImageFormat.Webp, processInvalidImages: false }, + { + colorspace: Colorspace.P3, + quality: 80, + format: ImageFormat.Webp, + processInvalidImages: false, + decodingTier: 0, + }, file, ); diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index 905708735..7f3bcfc3a 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -186,6 +186,7 @@ export class MediaRepository { // this is default in libvips (except the threshold is 90), but we need to set it manually in sharp chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0', progressive: options.progressive, + decodingTier: options.decodingTier, }) .toFile(output); } diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 950e1569b..5b0382a14 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -454,6 +454,7 @@ describe(MediaService.name, () => { progressive: false, processInvalidImages: false, edits: [], + decodingTier: 0, }, expect.any(String), ); @@ -467,6 +468,7 @@ describe(MediaService.name, () => { progressive: false, processInvalidImages: false, edits: [], + decodingTier: 4, }, expect.any(String), ); @@ -551,6 +553,30 @@ describe(MediaService.name, () => { ]); }); + it('should generate WebP video thumbnails when JPEG XL is configured', async () => { + const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); + mocks.systemMetadata.get.mockResolvedValue({ + image: { preview: { format: ImageFormat.Jxl }, thumbnail: { format: ImageFormat.Jxl } }, + }); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ + ...getForGenerateThumbnail(asset), + ...probeStub.videoStream2160p, + }); + + await sut.handleGenerateThumbnails({ id: asset.id }); + + expect(mocks.media.transcode).toHaveBeenCalledWith( + '/original/path.ext', + expect.stringMatching(/_preview\.webp$/), + expect.any(Object), + ); + expect(mocks.media.transcode).toHaveBeenCalledWith( + '/original/path.ext', + expect.stringMatching(/_thumbnail\.webp$/), + expect.any(Object), + ); + }); + it('should tonemap thumbnail for hdr video', async () => { const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ @@ -726,6 +752,7 @@ describe(MediaService.name, () => { progressive: false, processInvalidImages: false, edits: [], + decodingTier: 0, }, previewPath, ); @@ -739,6 +766,7 @@ describe(MediaService.name, () => { progressive: false, processInvalidImages: false, edits: [], + decodingTier: 4, }, thumbnailPath, ); @@ -774,6 +802,7 @@ describe(MediaService.name, () => { progressive: false, processInvalidImages: false, edits: [], + decodingTier: 0, }, previewPath, ); @@ -787,6 +816,7 @@ describe(MediaService.name, () => { progressive: false, processInvalidImages: false, edits: [], + decodingTier: 4, }, thumbnailPath, ); @@ -870,6 +900,24 @@ describe(MediaService.name, () => { ]); }); + it('should never set isProgressive for JPEG XL', async () => { + const asset = AssetFactory.from().exif().build(); + mocks.systemMetadata.get.mockResolvedValue({ + image: { + preview: { format: ImageFormat.Jxl, progressive: true }, + thumbnail: { format: ImageFormat.Jxl, progressive: true }, + }, + }); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); + + await sut.handleGenerateThumbnails({ id: asset.id }); + + expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ + expect.objectContaining({ type: AssetFileType.Preview, isProgressive: false }), + expect.objectContaining({ type: AssetFileType.Thumbnail, isProgressive: false }), + ]); + }); + it('should never set isProgressive for videos', async () => { const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); mocks.systemMetadata.get.mockResolvedValue({ @@ -1079,6 +1127,7 @@ describe(MediaService.name, () => { progressive: false, processInvalidImages: false, edits: [], + decodingTier: 0, }, expect.any(String), ); @@ -1114,6 +1163,7 @@ describe(MediaService.name, () => { progressive: false, processInvalidImages: false, edits: [], + decodingTier: 0, }, expect.any(String), ); @@ -1127,6 +1177,7 @@ describe(MediaService.name, () => { progressive: false, processInvalidImages: false, edits: [], + decodingTier: 0, }, expect.any(String), ); @@ -1160,6 +1211,7 @@ describe(MediaService.name, () => { progressive: false, processInvalidImages: false, edits: [], + decodingTier: 0, }, expect.any(String), ); @@ -1173,6 +1225,7 @@ describe(MediaService.name, () => { size: 1440, processInvalidImages: false, edits: [], + decodingTier: 0, }, expect.any(String), ); @@ -1210,6 +1263,7 @@ describe(MediaService.name, () => { progressive: false, processInvalidImages: false, edits: [], + decodingTier: 0, }, expect.any(String), ); @@ -1274,6 +1328,7 @@ describe(MediaService.name, () => { progressive: false, processInvalidImages: false, edits: [], + decodingTier: 0, }, expect.any(String), ); @@ -1316,6 +1371,7 @@ describe(MediaService.name, () => { progressive: false, processInvalidImages: false, edits: [], + decodingTier: 0, }, expect.any(String), ); @@ -1577,6 +1633,7 @@ describe(MediaService.name, () => { ], processInvalidImages: false, size: 250, + decodingTier: 4, }, expect.any(String), ); @@ -1630,6 +1687,7 @@ describe(MediaService.name, () => { ], processInvalidImages: false, size: 250, + decodingTier: 4, }, expect.any(String), ); @@ -1678,6 +1736,7 @@ describe(MediaService.name, () => { ], processInvalidImages: false, size: 250, + decodingTier: 4, }, expect.any(String), ); @@ -1722,6 +1781,7 @@ describe(MediaService.name, () => { ], processInvalidImages: false, size: 250, + decodingTier: 4, }, expect.any(String), ); @@ -1766,6 +1826,7 @@ describe(MediaService.name, () => { ], processInvalidImages: false, size: 250, + decodingTier: 4, }, expect.any(String), ); @@ -1810,6 +1871,7 @@ describe(MediaService.name, () => { ], processInvalidImages: false, size: 250, + decodingTier: 4, }, expect.any(String), ); @@ -1859,6 +1921,7 @@ describe(MediaService.name, () => { ], processInvalidImages: false, size: 250, + decodingTier: 4, }, expect.any(String), ); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 129b99a10..07ffb491c 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -10,7 +10,7 @@ import type { VideoInterfaces, VideoStreamInfo, } from 'src/types.js'; -import { FACE_THUMBNAIL_SIZE } from 'src/constants.js'; +import { FACE_THUMBNAIL_SIZE, THUMBNAIL_DECODING_TIER } from 'src/constants.js'; import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core.js'; import { AssetFile } from 'src/database.js'; import { OnEvent, OnJob } from 'src/decorators.js'; @@ -303,14 +303,14 @@ export class MediaService extends BaseService { fileType: AssetFileType.Preview, format: previewFormat, isEdited: useEdits, - isProgressive: !!image.preview.progressive && previewFormat !== ImageFormat.Webp, + isProgressive: !!image.preview.progressive && previewFormat === ImageFormat.Jpeg, isTransparent, }); const thumbnailFile = this.getImageFile(asset, { fileType: AssetFileType.Thumbnail, format: thumbnailFormat, isEdited: useEdits, - isProgressive: !!image.thumbnail.progressive && thumbnailFormat !== ImageFormat.Webp, + isProgressive: !!image.thumbnail.progressive && thumbnailFormat === ImageFormat.Jpeg, isTransparent, }); this.storageCore.ensureFolders(previewFile.path); @@ -318,8 +318,13 @@ export class MediaService extends BaseService { // generate final images const decoded = { data, info }; const baseOptions = { colorspace, processInvalidImages: false, edits: useEdits ? asset.edits : [] }; - const thumbnailOptions = { ...image.thumbnail, ...baseOptions, format: thumbnailFormat }; - const previewOptions = { ...image.preview, ...baseOptions, format: previewFormat }; + const thumbnailOptions = { + ...image.thumbnail, + ...baseOptions, + format: thumbnailFormat, + decodingTier: THUMBNAIL_DECODING_TIER, + } as const; + const previewOptions = { ...image.preview, ...baseOptions, format: previewFormat, decodingTier: 0 } as const; const promises = [ this.mediaRepository.generateThumbhash(decoded, baseOptions), this.mediaRepository.generateThumbnail(decoded, thumbnailOptions, thumbnailFile.path), @@ -335,7 +340,7 @@ export class MediaService extends BaseService { fileType: AssetFileType.FullSize, format: fullsizeFormat, isEdited: useEdits, - isProgressive: !!image.fullsize.progressive && fullsizeFormat !== ImageFormat.Webp, + isProgressive: !!image.fullsize.progressive && fullsizeFormat === ImageFormat.Jpeg, isTransparent, }); const fullsizeOptions = { @@ -343,14 +348,15 @@ export class MediaService extends BaseService { format: fullsizeFormat, quality: image.fullsize.quality, progressive: image.fullsize.progressive, - }; + decodingTier: 0, + } as const; promises.push(this.mediaRepository.generateThumbnail(decoded, fullsizeOptions, fullsizeFile.path)); } else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) { fullsizeFile = this.getImageFile(asset, { fileType: AssetFileType.FullSize, format: extracted.format, isEdited: false, - isProgressive: !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp, + isProgressive: !!image.fullsize.progressive && image.fullsize.format === ImageFormat.Jpeg, isTransparent, }); this.storageCore.ensureFolders(fullsizeFile.path); @@ -444,6 +450,7 @@ export class MediaService extends BaseService { ), }, ], + decodingTier: THUMBNAIL_DECODING_TIER, }; await this.mediaRepository.generateThumbnail(decoded, thumbnailOptions, thumbnailPath); @@ -493,14 +500,15 @@ export class MediaService extends BaseService { private async generateVideoThumbnails(asset: ThumbnailAsset, { ffmpeg, image }: SystemConfig) { const previewFile = this.getImageFile(asset, { fileType: AssetFileType.Preview, - format: image.preview.format, + // TODO: write JPEG XL for video thumbnails + format: image.preview.format === ImageFormat.Jxl ? ImageFormat.Webp : image.preview.format, isEdited: false, isProgressive: false, isTransparent: false, }); const thumbnailFile = this.getImageFile(asset, { fileType: AssetFileType.Thumbnail, - format: image.thumbnail.format, + format: image.thumbnail.format === ImageFormat.Jxl ? ImageFormat.Webp : image.thumbnail.format, isEdited: false, isProgressive: false, isTransparent: false, diff --git a/server/src/types.ts b/server/src/types.ts index e338a7fea..2dcf2ecc4 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -43,20 +43,15 @@ export type DeepPartial = T extends Date export type RepositoryInterface = Pick; -export type FullsizeImageOptions = { - format: ImageFormat; - quality: number; - enabled: boolean; - progressive?: boolean; -}; - export type ImageOptions = { format: ImageFormat; quality: number; - size: number; progressive?: boolean; + decodingTier: 0 | 1 | 2 | 3 | 4; }; +export type FullsizeImageOptions = ImageOptions & { enabled: boolean }; + export type RawImageInfo = { width: number; height: number; @@ -84,9 +79,7 @@ export type TransformOptions = { edits?: AssetEditActionItem[]; }; -export type GenerateThumbnailOptions = Pick & - ImageColorOptions & - TransformOptions; +export type GenerateThumbnailOptions = ImageOptions & ImageColorOptions & TransformOptions; export type GenerateThumbhashOptions = ImageColorOptions & Pick; export interface VideoStreamInfo { diff --git a/server/src/utils/profile-image.ts b/server/src/utils/profile-image.ts index 2fb3e9499..48b06d496 100644 --- a/server/src/utils/profile-image.ts +++ b/server/src/utils/profile-image.ts @@ -1,4 +1,5 @@ import { join } from 'node:path'; +import { THUMBNAIL_DECODING_TIER } from 'src/constants.js'; import { StorageCore } from 'src/cores/storage.core.js'; import { SystemConfig } from 'src/dtos/config.dto.js'; import { StorageFolder } from 'src/enum.js'; @@ -33,6 +34,7 @@ export const generateProfileImage = async ( progressive: image.thumbnail.progressive, size: image.thumbnail.size, processInvalidImages: false, + decodingTier: THUMBNAIL_DECODING_TIER, }, outputPath, ); diff --git a/web/src/routes/admin/system-settings/ImageSettings.svelte b/web/src/routes/admin/system-settings/ImageSettings.svelte index b80ed6bce..1c0f41bbf 100644 --- a/web/src/routes/admin/system-settings/ImageSettings.svelte +++ b/web/src/routes/admin/system-settings/ImageSettings.svelte @@ -36,13 +36,14 @@ bind:value={configToEdit.image.thumbnail.format} options={[ { value: ImageFormat.Jpeg, text: 'JPEG' }, + { value: ImageFormat.Jxl, text: 'JPEG XL' }, { value: ImageFormat.Webp, text: 'WebP' }, ]} name="format" isEdited={configToEdit.image.thumbnail.format !== config.image.thumbnail.format} {disabled} onSelect={(value) => { - if (value === ImageFormat.Webp) { + if (value !== ImageFormat.Jpeg) { configToEdit.image.thumbnail.progressive = false; } }} @@ -80,7 +81,7 @@ checked={configToEdit.image.thumbnail.progressive} onToggle={(isChecked) => (configToEdit.image.thumbnail.progressive = isChecked)} isEdited={configToEdit.image.thumbnail.progressive !== config.image.thumbnail.progressive} - disabled={disabled || configToEdit.image.thumbnail.format === ImageFormat.Webp} + disabled={disabled || configToEdit.image.thumbnail.format !== ImageFormat.Jpeg} /> @@ -95,13 +96,14 @@ bind:value={configToEdit.image.preview.format} options={[ { value: ImageFormat.Jpeg, text: 'JPEG' }, + { value: ImageFormat.Jxl, text: 'JPEG XL' }, { value: ImageFormat.Webp, text: 'WebP' }, ]} name="format" isEdited={configToEdit.image.preview.format !== config.image.preview.format} {disabled} onSelect={(value) => { - if (value === ImageFormat.Webp) { + if (value !== ImageFormat.Jpeg) { configToEdit.image.preview.progressive = false; } }} @@ -138,7 +140,7 @@ checked={configToEdit.image.preview.progressive} onToggle={(isChecked) => (configToEdit.image.preview.progressive = isChecked)} isEdited={configToEdit.image.preview.progressive !== config.image.preview.progressive} - disabled={disabled || configToEdit.image.preview.format === ImageFormat.Webp} + disabled={disabled || configToEdit.image.preview.format !== ImageFormat.Jpeg} /> @@ -164,13 +166,14 @@ bind:value={configToEdit.image.fullsize.format} options={[ { value: ImageFormat.Jpeg, text: 'JPEG' }, + { value: ImageFormat.Jxl, text: 'JPEG XL' }, { value: ImageFormat.Webp, text: 'WebP' }, ]} name="format" isEdited={configToEdit.image.fullsize.format !== config.image.fullsize.format} disabled={disabled || !configToEdit.image.fullsize.enabled} onSelect={(value) => { - if (value === ImageFormat.Webp) { + if (value !== ImageFormat.Jpeg) { configToEdit.image.fullsize.progressive = false; } }} @@ -193,7 +196,7 @@ isEdited={configToEdit.image.fullsize.progressive !== config.image.fullsize.progressive} disabled={disabled || !configToEdit.image.fullsize.enabled || - configToEdit.image.fullsize.format === ImageFormat.Webp} + configToEdit.image.fullsize.format !== ImageFormat.Jpeg} />