HEIC gets a thumbnail, through the decoder that can actually read it
sharp ships its own libvips and libheif built WITHOUT the HEVC decoder, so a HEIC parses its header, reports 4032x3024, and then fails on the pixels with "bad seek". that reads like a corrupt file and is not one — every photograph an iPhone takes is HEVC-coded HEIF, so it is most of what arrives from a phone. ImageMagick links the SYSTEM libheif, which has the codec. so a `.heic` goes through `convert` to a lossless PNG and sharp takes it from there. PNG rather than JPEG for the intermediate so the single real encode is the final one and the thumbnail is not a copy of a copy. removed in a `finally` either way — a failed decode must not leave a stray file a later run mistakes for a finished thumbnail. this is deliberately the same approach av-club has used for weeks (sidecar/sharp/decodable.ts), on these exact files. 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 cheaper than inverting it. `convert` exits 0 having written nothing when the codec is missing, so the OUTPUT FILE is the test rather than the exit code. that is not defensive: it is precisely the failure this exists to handle. IT NEEDS `libheif-plugin-libde265`, which is not in a default Ubuntu install — libheif packages its codecs separately and ships the AV1 one, not the HEVC one. declared in av-club's manifest today, and installed on this host to prove the path. without it, `convert` fails the same way sharp does and the browser falls back to the file icon, which is the correct degradation rather than a break. the in-memory failure cache did exactly what its comment said it would: the HEIC stayed 422 after the codec was installed, until a restart. "a restart is the natural moment to give a file another chance after somebody has installed a codec" — written weeks ago, met today. verified: a real iPhone HEIC, 2 MB in, 12 KB out, 5.3s cold and 22ms cached, and the image is right. png, svg and mp4 unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, stat } from 'node:fs/promises';
|
||||
import { mkdir, rm, stat } from 'node:fs/promises';
|
||||
import { extname, join } from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
|
||||
@@ -179,6 +179,44 @@ 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 })
|
||||
@@ -237,7 +275,25 @@ 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);
|
||||
}
|
||||
} 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);
|
||||
|
||||
Reference in New Issue
Block a user