Skip to content

Commit ad588a4

Browse files
authored
v1.5.30: camera RAW thumbnails via the vips shim (#99) (#102)
1 parent 4cf5170 commit ad588a4

6 files changed

Lines changed: 182 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## 1.5.30 - 2026-07-14
4+
5+
### Fixed
6+
- **Camera RAW thumbnails failed** (#99): Canon CR2 (and other camera RAW such as Nikon NEF, Sony ARW, Adobe DNG, Fujifilm RAF) died in `AssetGenerateThumbnails` with `tiff2vips: Old-style JPEG compression support is not configured`. Sharp's prebuilt libvips on macOS lacks old-style-JPEG support and has no dcraw/libraw loader, so it cannot read a RAW file's embedded image. The HEIC decode shim now also detects RAW by extension and pre-decodes it via Homebrew `vips` (fuller libtiff/libjpeg for TIFF-based RAW like CR2/NEF/ARW/DNG, plus libraw for the rest), the same libvips Docker Immich uses, before handing the result to Sharp. Works headless; OCR and face are untouched (RAW decode affects thumbnail generation only). Verified on an M4 Mac Mini with a real Canon CR2: bundled Sharp alone fails with the tiff2vips error, and with the shim it produces a valid thumbnail. Closes another macOS-only decode gap after the HEIC fix (v1.5.28). Reported by [@shtefko](https://github.com/shtefko).
7+
- **Headless HEIC decode was silently falling back to `sips`**: Sharp's prebuilt libvips exports a bogus `VIPSHOME` (the GitHub-runner path it was built in, e.g. `/Users/runner/work/sharp-libvips/...`) into the process environment when it loads. The decode shim spawned the Homebrew `vips` with that inherited `VIPSHOME`, so `vips` looked for its loader modules (libheif for HEIC, the RAW loaders) in a nonexistent directory and failed with `VipsForeignLoad: ... is not a known file format`, falling back to Apple's `sips`. Because `sips` needs a logged-in GUI session, on a headless Mac the primary `vips` path (the entire point of the v1.5.28 HEIC fix) never actually ran, so HEIC thumbnails quietly depended on a desktop session. The shim now drops a `VIPSHOME` that points at a nonexistent directory before spawning a decoder, so `vips` uses its own compiled-in module path and decodes HEIC and RAW headless as intended; an intentional, existing `VIPSHOME` is preserved. Surfaced while adding RAW support and verified on a headless M4 Mac Mini (HEIC now decodes via `vips`, not `sips`).
8+
9+
### Performance
10+
- The `vips` decode intermediate is now written as a lossless deflate-compressed TIFF (roughly 40% smaller than uncompressed) to trim peak memory and temp I/O when decoding large RAW frames. Pixels are unchanged; Sharp reads the deflate TIFF the same way.
11+
312
## 1.5.29 - 2026-07-14
413

514
### Changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,11 +221,13 @@ The native worker runs Immich's unmodified code. The ffmpeg and image processing
221221
| **ML: Face recognition** | ONNX Runtime | ONNX Runtime with CoreML | Same model, CoreML acceleration. Numerically close embeddings. |
222222
| **ML: OCR** | PaddleOCR via ONNX | Apple Vision framework (Neural Engine) | Different engine. Vision framework OCR is generally more accurate for Latin text, may differ for CJK. |
223223
| **HEIC decode** | libvips built with libde265 | Homebrew `vips` (libvips + libde265), then Sharp | Sharp's prebuilt libvips on macOS has no HEVC decoder, so iPhone HEICs are pre-decoded by the Homebrew `vips` (the same libvips + libde265 Docker uses) before Sharp processes them. Works headless; pixels match Docker. Apple ImageIO (`sips`) is a last-resort fallback for a logged-in desktop only. |
224+
| **Camera RAW decode** | libvips with fuller libtiff/libjpeg + libraw | Homebrew `vips`, then Sharp | Sharp's prebuilt libvips on macOS lacks old-style-JPEG and dcraw/libraw support, so Canon CR2/CR3, Nikon NEF, Sony ARW, Adobe DNG and other RAW originals fail thumbnail generation (`tiff2vips: Old-style JPEG compression support is not configured`, or a `multiband -> srgb` colourspace error). They are pre-decoded by the Homebrew `vips` (fuller libtiff/libjpeg for TIFF-based RAW, plus libraw for the rest), the same libvips Docker uses, before Sharp. Works headless; matches Docker. |
224225

225226
### What this means in practice
226227

227228
- **Thumbnails, previews, and video**: Identical to Docker. Same jellyfin-ffmpeg binary, same `tonemapx` HDR tone mapping, same output. VideoToolbox hardware encoding is faster but visually equivalent.
228229
- **HEIC photos**: Thumbnails generate correctly. The default iPhone format (HEVC-coded HEIC, often tiled) is decoded by the Homebrew `vips` (libvips + libde265, a formula dependency) since Sharp's bundled libheif is AVIF-only. This works with no logged-in GUI session (a headless Mac Mini), and output matches Docker. Apple ImageIO (`sips`) remains a fallback but needs a GUI session, so it isn't relied on.
230+
- **Camera RAW photos** (Canon CR2/CR3, Nikon NEF, Sony ARW, Adobe DNG, and other RAW formats): Thumbnails generate correctly. Sharp's bundled libvips on macOS can't decode these (no old-style-JPEG support, no dcraw/libraw), so they're pre-decoded by the Homebrew `vips` (fuller libtiff/libjpeg plus libraw), the same libvips Docker uses. Works headless.
229231
- **CLIP search**: Search results are equivalent but not identical. A search that returns 20 results in Docker will return ~18-20 of the same results natively, possibly in slightly different order.
230232
- **Face grouping**: Faces are detected and grouped correctly. The grouping boundaries may differ slightly (e.g., a borderline face might be grouped differently).
231233
- **OCR**: Text extraction is at least as good as Docker for English/Latin text.

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.5.29
1+
1.5.30

immich_accelerator/__main__.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3873,10 +3873,11 @@ def cmd_start(args):
38733873
f"{existing} {require_arg}".strip() if existing else require_arg
38743874
)
38753875

3876-
# HEIC decode shim (issue #62 follow-up): Sharp's prebuilt libvips ships
3877-
# without an HEVC decoder (AVIF-only), so iPhone HEICs fail to decode and
3878-
# never get thumbnails. This preload wraps the `sharp` module to route
3879-
# HEVC-HEIC file paths through Apple's ImageIO (`sips`) before Sharp.
3876+
# HEIC + camera-RAW decode shim (issues #62, #99): Sharp's prebuilt libvips
3877+
# ships without an HEVC decoder (AVIF-only) and without a dcraw/libraw
3878+
# loader, so iPhone HEICs and Canon/Nikon/Sony RAW files fail to decode and
3879+
# never get thumbnails. This preload wraps the `sharp` module to route those
3880+
# file paths through Homebrew libvips (to a lossless TIFF) before Sharp.
38803881
# Same --require interposition; Immich's source on disk is untouched.
38813882
heic_shim = Path(__file__).parent / "hooks" / "heic_decode_shim.js"
38823883
if heic_shim.exists():

immich_accelerator/hooks/heic_decode_shim.js

Lines changed: 93 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,29 @@
11
// heic_decode_shim.js
22
//
3-
// Runtime interposition for HEIC decoding in Immich's media pipeline.
3+
// Runtime interposition for HEIC and camera-RAW decoding in Immich's media
4+
// pipeline.
45
//
5-
// The native worker uses Sharp's prebuilt libvips (8.17.x), whose bundled
6-
// libheif is compiled WITHOUT an HEVC decoder — it supports AVIF only. So
7-
// every HEIC original (the default iPhone format) fails to decode with a
8-
// "bad seek" / "compression format has not been built in" error, and no
9-
// thumbnail is produced (issue #62 follow-up). Docker Immich isn't affected
10-
// because it builds libvips with libde265.
6+
// The native worker uses Sharp's prebuilt libvips (8.17.x). Two format families
7+
// it cannot decode on macOS, both of which Docker Immich handles fine:
8+
//
9+
// 1. HEVC-HEIC (the default iPhone format): Sharp's bundled libheif is
10+
// compiled WITHOUT an HEVC decoder (AVIF only), so every HEIC original
11+
// fails with "bad seek" / "compression format has not been built in" and
12+
// no thumbnail is produced (issue #62). Docker builds libvips with
13+
// libde265, so it is unaffected.
14+
// 2. Camera RAW (Canon CR2/CR3, Nikon NEF, Sony ARW, Adobe DNG, and friends):
15+
// Sharp's bundled libtiff/libjpeg lack old-style-JPEG support and it has no
16+
// dcraw/libraw loader, so a CR2 dies in AssetGenerateThumbnails with
17+
// "tiff2vips: Old-style JPEG compression support is not configured"
18+
// (issue #99). Docker's libvips (fuller libtiff plus libraw) reads it.
1119
//
1220
// Fix: preload this module via `NODE_OPTIONS=--require ...` and wrap the
13-
// `sharp` module export. When Sharp is handed a HEVC-family HEIC file path, we
14-
// transcode it to a lossless intermediate with an external decoder and feed
15-
// that buffer to the real Sharp instead. Everything else (JPEG, PNG, AVIF,
16-
// buffers, streams) passes through untouched. Immich's source on disk is never
17-
// modified — same interposition pattern as the ffmpeg wrapper and pg_dump shim.
21+
// `sharp` module export. When Sharp is handed a HEVC-HEIC or camera-RAW file
22+
// path, we transcode it to a lossless TIFF with Homebrew's fuller libvips and
23+
// feed that buffer to the real Sharp instead. Everything else (JPEG, PNG, AVIF,
24+
// ordinary TIFF, buffers, streams) passes through untouched. Immich's source on
25+
// disk is never modified, the same interposition pattern as the ffmpeg wrapper
26+
// and pg_dump shim.
1827
//
1928
// Decoder preference (first that yields a valid TIFF wins):
2029
// 1. libheif via `vips autorot` (Homebrew libvips built WITH libde265) — a
@@ -73,8 +82,14 @@ const SIPS = findBin('IMMICH_ACCELERATOR_SIPS', ['/usr/bin/sips']);
7382
const DECODERS = [
7483
{
7584
name: 'vips',
85+
// Write the intermediate TIFF with lossless deflate compression. Sharp
86+
// ingests the whole file into a Node Buffer, and a camera RAW decodes to
87+
// a large frame (tens of MB uncompressed, ~40% smaller deflated), so this
88+
// trims peak memory and tmp I/O for no pixel change and negligible CPU
89+
// (the decode dominates). The magic bytes are unaffected, so isTiff still
90+
// gates it. sips (fallback) has no equivalent option and is left plain.
7691
bin: VIPS,
77-
args: (input, out) => ['autorot', input, out],
92+
args: (input, out) => ['autorot', input, `${out}[compression=deflate]`],
7893
},
7994
{
8095
name: 'sips',
@@ -86,7 +101,7 @@ const DECODERS = [
86101
// One-time startup line so the worker log shows the shim is active and which
87102
// HEIC decoders it resolved (invaluable when HEIC thumbnails misbehave).
88103
process.stderr.write(
89-
'[immich-accelerator] heic-decode-shim active ' +
104+
'[immich-accelerator] heic/raw decode shim active ' +
90105
`(decoders: vips=${VIPS || 'no'}, sips=${SIPS || 'no'})\n`
91106
);
92107

@@ -131,6 +146,44 @@ function isHevcHeicPath(input) {
131146
return brands !== null && brands.some((b) => HEVC_BRANDS.has(b));
132147
}
133148

149+
// Camera-RAW extensions that Sharp's bundled libvips cannot decode on macOS
150+
// (its libtiff/libjpeg lack old-style-JPEG support and it has no dcraw/libraw
151+
// loader) but Homebrew libvips can. RAW is matched by EXTENSION, not magic:
152+
// most RAW containers are TIFF-based, so their magic bytes don't reliably
153+
// separate them from an ordinary TIFF that Sharp already handles. Routing these
154+
// through `vips autorot` mirrors Docker Immich's own libvips+libraw path; if a
155+
// file with one of these extensions is not actually decodable RAW, the decoder
156+
// fails the TIFF check and we fall back to the real Sharp (no worse than now).
157+
// Generic `.raw` is intentionally omitted: it is too ambiguous (also raw audio,
158+
// disk images, etc.) to intercept safely.
159+
const RAW_EXTS = new Set([
160+
'cr2', 'cr3', 'crw', // Canon
161+
'nef', 'nrw', // Nikon
162+
'arw', 'sr2', 'srf', // Sony
163+
'raf', // Fujifilm
164+
'orf', 'ori', // Olympus
165+
'rw2', // Panasonic
166+
'pef', // Pentax
167+
'dng', // Adobe / generic
168+
'rwl', // Leica
169+
'dcr', 'kdc', 'k25', // Kodak
170+
'mrw', // Minolta
171+
'x3f', // Sigma
172+
'3fr', 'fff', // Hasselblad
173+
'mos', 'iiq', // Leaf / Phase One
174+
'erf', // Epson
175+
'mef', // Mamiya
176+
'mdc', // Minolta / Agfa
177+
'ari', 'cap', // Arri / others
178+
]);
179+
180+
function isRawPath(input) {
181+
if (typeof input !== 'string' || input.length === 0) return false;
182+
const dot = input.lastIndexOf('.');
183+
if (dot < 0) return false;
184+
return RAW_EXTS.has(input.slice(dot + 1).toLowerCase());
185+
}
186+
134187
// The decode runs synchronously in the sharp() constructor, so a hung decoder
135188
// stalls the worker's event loop. Cap the TOTAL time across all decoders (not
136189
// per-decoder) so trying the fallback can't multiply the worst case.
@@ -148,6 +201,26 @@ function isTiff(buf) {
148201
);
149202
}
150203

204+
// Sharp's prebuilt libvips exports VIPSHOME into the process environment,
205+
// pointing at the build tree it was compiled in (a GitHub-runner path baked into
206+
// the binary, e.g. /Users/runner/work/sharp-libvips/...). When we spawn the
207+
// Homebrew `vips`, it inherits that VIPSHOME and hunts for its loader modules
208+
// (libheif for HEIC, etc.) under that bogus directory, so the decode fails with
209+
// "not a known file format" and silently falls back to sips (which needs a GUI
210+
// session, so it produces nothing headless). If VIPSHOME points at a directory
211+
// that does not exist, drop it from the child's env so the spawned vips uses its
212+
// own compiled-in module path. A real, existing VIPSHOME (an intentional
213+
// operator override) is left untouched.
214+
function decoderEnv() {
215+
const home = process.env.VIPSHOME;
216+
if (home && !fs.existsSync(home)) {
217+
const env = { ...process.env };
218+
delete env.VIPSHOME;
219+
return env;
220+
}
221+
return process.env;
222+
}
223+
151224
// Decode a HEIC file to a lossless TIFF buffer, trying each available decoder in
152225
// preference order until one yields a valid TIFF. Returns the buffer, or null if
153226
// every decoder is unavailable or fails (caller falls back to the real Sharp, so
@@ -168,6 +241,7 @@ function decodeToBuffer(input) {
168241
cp.execFileSync(dec.bin, dec.args(input, tmp), {
169242
stdio: ['ignore', 'ignore', 'pipe'],
170243
timeout: remaining,
244+
env: decoderEnv(),
171245
});
172246
const buf = fs.readFileSync(tmp);
173247
if (isTiff(buf)) return buf;
@@ -186,8 +260,8 @@ function decodeToBuffer(input) {
186260
}
187261
if (attempted === 0) {
188262
process.stderr.write(
189-
'[immich-accelerator] No HEIC decoder found (install vips for ' +
190-
'headless HEIC support); falling back to Sharp.\n'
263+
'[immich-accelerator] No HEIC/RAW decoder found (install vips for ' +
264+
'headless HEIC and camera-RAW support); falling back to Sharp.\n'
191265
);
192266
}
193267
return null;
@@ -196,7 +270,9 @@ function decodeToBuffer(input) {
196270
// Wrap the real Sharp factory so HEVC-HEIC paths are pre-decoded.
197271
function wrapSharp(realSharp) {
198272
function sharp(input, options) {
199-
if (isHevcHeicPath(input)) {
273+
// isRawPath is a pure string check; isHevcHeicPath reads the ftyp box.
274+
// Test the cheap one first so RAW paths route with no filesystem I/O.
275+
if (isRawPath(input) || isHevcHeicPath(input)) {
200276
const buf = decodeToBuffer(input);
201277
if (buf !== null) {
202278
return realSharp(buf, options);

tests/test_fresh_install.py

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1379,9 +1379,10 @@ def test_create_app_succeeds_with_minimal_deps(self, tmp_path):
13791379

13801380

13811381
class TestHeicDecodeShim:
1382-
"""The HEIC decode shim (issue #62 follow-up) interposes on require('sharp')
1383-
to route HEVC-HEIC paths through Apple's ImageIO. These guard the JS logic
1384-
without needing real Sharp/sips (the real decode is verified on-device)."""
1382+
"""The HEIC/RAW decode shim (issues #62, #99) interposes on require('sharp')
1383+
to route HEVC-HEIC paths (by ftyp brand) and camera-RAW paths (by extension)
1384+
through Homebrew libvips. These guard the JS logic without needing real
1385+
Sharp/vips (the real decode is verified on-device)."""
13851386

13861387
SHIM = REPO_ROOT / "immich_accelerator" / "hooks" / "heic_decode_shim.js"
13871388

@@ -1425,8 +1426,11 @@ def _fake_sharp(self, tmp_path):
14251426

14261427
def _stub(self, path_, body):
14271428
# Decoder stubs: the output path is always the last argument (vips
1428-
# `autorot in out`, sips `... --out out`).
1429-
path_.write_text('#!/bin/bash\nout="${@: -1}"\n' + body)
1429+
# `autorot in out[compression=deflate]`, sips `... --out out`). vips
1430+
# accepts a trailing `[options]` suffix on the save target and writes to
1431+
# the base path; strip it (`[[]` is a glob for a literal `[`) so the stub
1432+
# writes where the shim reads back.
1433+
path_.write_text('#!/bin/bash\nout="${@: -1}"\nout="${out%%[[]*}"\n' + body)
14301434
path_.chmod(0o755)
14311435

14321436
# A minimal little-endian TIFF header (II*\0) — enough for the shim's isTiff
@@ -1531,6 +1535,69 @@ def test_falls_through_to_sips_when_vips_produces_empty(self, tmp_path):
15311535
assert "INPUT_TYPE:buffer" in result.stdout, result.stdout
15321536
assert sips_used.exists(), "must fall through to sips when vips output is empty"
15331537

1538+
def test_routes_raw_by_extension_and_leaves_ordinary_tiff_alone(self, tmp_path):
1539+
"""Camera RAW is matched by EXTENSION (issue #99): a .cr2/.nef path is
1540+
decoded to a Buffer via vips, while an ordinary .tif (which Sharp handles
1541+
natively) passes through untouched as a string. Guards against the
1542+
extension set over-matching plain TIFFs."""
1543+
node = self._node_or_skip()
1544+
self._fake_sharp(tmp_path)
1545+
vips = tmp_path / "vips_stub.sh"
1546+
self._stub(vips, self._TIFF)
1547+
cr2 = tmp_path / "photo.cr2"
1548+
cr2.write_bytes(b"content-irrelevant-extension-is-what-matters")
1549+
nef = tmp_path / "photo.nef"
1550+
nef.write_bytes(b"II*\x00whatever")
1551+
plain_tif = tmp_path / "scan.tif"
1552+
plain_tif.write_bytes(b"II*\x00ordinary-tiff-sharp-handles-this")
1553+
1554+
result = self._run(
1555+
tmp_path,
1556+
node,
1557+
{"IMMICH_ACCELERATOR_VIPS": str(vips)},
1558+
[str(cr2), str(nef), str(plain_tif)],
1559+
)
1560+
assert result.returncode == 0, f"driver failed: {result.stderr}"
1561+
types = [ln for ln in result.stdout.splitlines() if ln.startswith("INPUT_TYPE:")]
1562+
assert types == [
1563+
"INPUT_TYPE:buffer",
1564+
"INPUT_TYPE:buffer",
1565+
"INPUT_TYPE:string",
1566+
], result.stdout
1567+
1568+
def test_strips_nonexistent_vipshome_from_decoder_env(self, tmp_path):
1569+
"""Sharp's prebuilt libvips exports a bogus VIPSHOME (its GitHub-runner
1570+
build path) into the environment. If the spawned Homebrew vips inherits
1571+
it, vips looks for its loader modules there, fails ("not a known file
1572+
format"), and decode silently falls to sips (GUI-only). The shim drops a
1573+
VIPSHOME pointing at a nonexistent dir but keeps a real one."""
1574+
node = self._node_or_skip()
1575+
self._fake_sharp(tmp_path)
1576+
marker = tmp_path / "vipshome_seen"
1577+
vips = tmp_path / "vips_stub.sh"
1578+
self._stub(vips, 'echo "[${VIPSHOME-UNSET}]" > ' + str(marker) + '\n' + self._TIFF)
1579+
heic = tmp_path / "photo.heic"
1580+
heic.write_bytes(self._HEIC_BYTES)
1581+
1582+
bogus = tmp_path / "does-not-exist"
1583+
r = self._run(
1584+
tmp_path, node,
1585+
{"IMMICH_ACCELERATOR_VIPS": str(vips), "VIPSHOME": str(bogus)},
1586+
[str(heic)],
1587+
)
1588+
assert r.returncode == 0, r.stderr
1589+
assert marker.read_text().strip() == "[UNSET]", "nonexistent VIPSHOME must be stripped"
1590+
1591+
real = tmp_path / "real-vipshome"
1592+
real.mkdir()
1593+
r2 = self._run(
1594+
tmp_path, node,
1595+
{"IMMICH_ACCELERATOR_VIPS": str(vips), "VIPSHOME": str(real)},
1596+
[str(heic)],
1597+
)
1598+
assert r2.returncode == 0, r2.stderr
1599+
assert marker.read_text().strip() == "[" + str(real) + "]", "existing VIPSHOME must be preserved"
1600+
15341601
class TestPgKeepaliveShim:
15351602
"""The pg keepalive shim sets keepAlive on Immich's Postgres connections so
15361603
a stateful firewall between worker and a remote DB can't reap idle

0 commit comments

Comments
 (0)