Compare commits

...
2 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 9cb875ca79 core stops claiming HEIC it cannot decode
the revert above took the ImageMagick path back out of core, where it should never
have gone: decoding HEIC needs ImageMagick and libheif-plugin-libde265, and the
core has no business acquiring a system dependency for one format. that is a
plugin's job.

this removes the other half of the same mistake. `heic` and `heif` were in core's
extension list from the day the thumbnailer was written, which made `ls` promise a
thumbnail sharp cannot produce — so every HEIC in a folder cost a request that
422'd before falling back to the icon, and the promise was the only reason the
request happened.

so without a plugin the file browser is now what it was: a file icon for a HEIC,
and no wasted round trip earning it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 07:41:17 +01:00
pastilhas c5b95becd2 Revert "HEIC gets a thumbnail, through the decoder that can actually read it"
This reverts commit f4b9596a88.
2026-08-29 07:39:52 +01:00
+14 -59
View File
@@ -1,5 +1,5 @@
import { createHash } from 'node:crypto';
import { mkdir, rm, stat } from 'node:fs/promises';
import { mkdir, stat } from 'node:fs/promises';
import { extname, join } from 'node:path';
import sharp from 'sharp';
@@ -43,7 +43,18 @@ import sharp from 'sharp';
* two are not exotic — HEIC is what every recent iPhone writes, and a folder of them showing generic
* file icons was the most visible gap this had.
*/
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'tif', 'tiff', 'svg', 'heic', 'heif']);
/*
No `heic` or `heif`, and that is not an oversight.
sharp's bundled libheif is built without the HEVC decoder, so it parses a HEIC's header and then
fails on the pixels. Listing them here made `ls` promise a thumbnail core cannot produce: every HEIC
cost a request that 422'd before falling back to the icon, and the promise was the only reason the
request happened at all.
Decoding them needs ImageMagick and `libheif-plugin-libde265` — a system dependency the core has no
business acquiring for one format. That belongs to a plugin, and av-club already does it.
*/
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'tif', 'tiff', 'svg']);
/*
`ts` is NOT here, and that is the one entry worth explaining.
@@ -179,44 +190,6 @@ const spawnFfmpeg = async (args: string[], output: string): Promise<boolean> =>
* Errors are swallowed to `false` rather than thrown — a file sharp cannot read is a fact about the
* file, and the caller already turns that into a fallback icon and a remembered failure.
*/
/** Extensions sharp's bundled libheif cannot decode — see `decodeViaImageMagick`. */
const NEEDS_IMAGEMAGICK = new Set(['heic', 'heif']);
/**
* Get a HEIC into something sharp can actually read.
*
* ── Why this is necessary at all ──
*
* sharp ships its own libvips and libheif, built WITHOUT the HEVC decoder — so it parses a HEIC's
* header, reports 4032x3024 quite happily, and then fails on the pixels with "bad seek". That reads
* exactly like a corrupt file and is not one. Every photograph an iPhone takes is HEVC-coded HEIF, so
* this is not an edge case; it is most of what arrives from a phone.
*
* ImageMagick links the SYSTEM libheif, which does have the codec — provided
* `libheif-plugin-libde265` is installed. That package is the entire difference: without it,
* ImageMagick fails the same way with "Unsupported codec". Observed on two hosts running identical
* libheif 1.17.6, one working and one not.
*
* ── This is deliberately the same approach av-club already uses ──
*
* `plugins/av-club/sidecar/sharp/decodable.ts` solved this first and has been converting these exact
* files for weeks. Copied rather than shared because the platform must not import from a plugin — the
* dependency only runs the other way — and fifteen lines of spawn is a smaller cost than inverting that.
*
* PNG for the intermediate, not JPEG: it is lossless, so the single real encode is the final one and the
* thumbnail is not a copy of a copy.
*/
const decodeViaImageMagick = async (input: string, output: string): Promise<boolean> => {
const proc = Bun.spawn(['convert', `${input}[0]`, output], { stdout: 'ignore', stderr: 'ignore' });
const timer = setTimeout(() => proc.kill(), 30_000);
await proc.exited;
clearTimeout(timer);
// The file is the test, never the exit code — ImageMagick exits 0 having written nothing when the
// codec is missing, which is precisely the case this exists to handle.
const written = await stat(output).catch(() => null);
return written !== null && written.size > 0;
};
const resizeImage = async (input: string, output: string, size: number): Promise<boolean> => {
try {
await sharp(input, { failOn: 'none', limitInputPixels: false, animated: false })
@@ -275,25 +248,7 @@ export async function buildThumbnail(
let ok: boolean;
if (kind === 'image') {
/*
HEIC goes through ImageMagick first. Everything else — jpeg, png, webp, avif, svg — sharp reads
directly, so this costs nothing for the common case.
The intermediate lands in the same cache directory and is removed either way: a failure here
must not leave a stray PNG that a later run mistakes for a finished thumbnail.
*/
const ext = extname(absPath).slice(1).toLowerCase();
if (NEEDS_IMAGEMAGICK.has(ext)) {
const intermediate = `${cachePath}.decoded.png`;
try {
ok =
(await decodeViaImageMagick(absPath, intermediate)) && (await resizeImage(intermediate, cachePath, size));
} finally {
await rm(intermediate, { force: true });
}
} else {
ok = await resizeImage(absPath, cachePath, size);
}
ok = await resizeImage(absPath, cachePath, size);
} else {
// `-ss` BEFORE `-i` is the fast seek — it jumps rather than decoding a second of video first.
ok = await spawnFfmpeg(['-ss', SEEK_SECONDS, '-i', absPath, ...common], cachePath);