Use heic-convert for HEIC-to-JPEG conversion

Sharp's prebuilt libvips lacks HEIF codec support. Replace with
heic-convert (pure JS decoder) for reliable HEIC conversion on all
platforms. Existing HEIC files on disk will be converted on-the-fly
when served via /api/files.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
denshooter
2026-02-22 02:01:04 +01:00
parent 6f826c66ea
commit 31dff10636
6 changed files with 74 additions and 6 deletions
+51
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"framer-motion": "^11.2.0", "framer-motion": "^11.2.0",
"heic-convert": "^2.1.0",
"lucide-react": "^0.400.0", "lucide-react": "^0.400.0",
"next": "^16.1.6", "next": "^16.1.6",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
@@ -2334,6 +2335,41 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/heic-convert": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/heic-convert/-/heic-convert-2.1.0.tgz",
"integrity": "sha512-1qDuRvEHifTVAj3pFIgkqGgJIr0M3X7cxEPjEp0oG4mo8GFjq99DpCo8Eg3kg17Cy0MTjxpFdoBHOatj7ZVKtg==",
"license": "ISC",
"dependencies": {
"heic-decode": "^2.0.0",
"jpeg-js": "^0.4.4",
"pngjs": "^6.0.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/heic-convert/node_modules/pngjs": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
"license": "MIT",
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/heic-decode": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz",
"integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==",
"license": "ISC",
"dependencies": {
"libheif-js": "^1.19.8"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/is-binary-path": { "node_modules/is-binary-path": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -2415,12 +2451,27 @@
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
}, },
"node_modules/jpeg-js": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
"license": "BSD-3-Clause"
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/libheif-js": {
"version": "1.19.8",
"resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.19.8.tgz",
"integrity": "sha512-vQJWusIxO7wavpON1dusciL8Go9jsIQ+EUrckauFYAiSTjcmLAsuJh3SszLpvkwPci3JcL41ek2n+LUZGFpPIQ==",
"license": "LGPL-3.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/lilconfig": { "node_modules/lilconfig": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+1
View File
@@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"framer-motion": "^11.2.0", "framer-motion": "^11.2.0",
"heic-convert": "^2.1.0",
"lucide-react": "^0.400.0", "lucide-react": "^0.400.0",
"next": "^16.1.6", "next": "^16.1.6",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
+2 -2
View File
@@ -3,7 +3,7 @@ import { writeFile, mkdir } from 'fs/promises'
import path from 'path' import path from 'path'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import { getDb } from '@/lib/db' import { getDb } from '@/lib/db'
import sharp from 'sharp' import { convertHeicToJpeg } from '@/lib/heic'
export const runtime = 'nodejs' export const runtime = 'nodejs'
export const maxDuration = 60 export const maxDuration = 60
@@ -77,7 +77,7 @@ export async function POST(req: NextRequest) {
let finalExt = ext let finalExt = ext
if (mimeType === 'image/heic' || mimeType === 'image/heif') { if (mimeType === 'image/heic' || mimeType === 'image/heif') {
try { try {
finalBuffer = Buffer.from(await sharp(buffer).jpeg({ quality: 90 }).toBuffer()) finalBuffer = await convertHeicToJpeg(buffer)
finalExt = '.jpg' finalExt = '.jpg'
} catch { } catch {
// Conversion failed — keep original // Conversion failed — keep original
+2 -1
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import sharp from 'sharp' import sharp from 'sharp'
import { convertHeicToJpeg } from '@/lib/heic'
export const runtime = 'nodejs' export const runtime = 'nodejs'
@@ -55,7 +56,7 @@ export async function GET(
contentType = 'image/jpeg' contentType = 'image/jpeg'
} else { } else {
try { try {
const converted = await sharp(fs.readFileSync(filePath), { failOn: 'none' }).jpeg({ quality: 90 }).toBuffer() const converted = await convertHeicToJpeg(fs.readFileSync(filePath))
fs.writeFileSync(jpegPath, converted) fs.writeFileSync(jpegPath, converted)
fileToSendPath = jpegPath fileToSendPath = jpegPath
ext = '.jpg' ext = '.jpg'
+3 -3
View File
@@ -4,7 +4,7 @@ import path from 'path'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { createHash, randomUUID } from 'crypto' import { createHash, randomUUID } from 'crypto'
import { getDb } from '@/lib/db' import { getDb } from '@/lib/db'
import sharp from 'sharp' import { convertHeicToJpeg } from '@/lib/heic'
export const runtime = 'nodejs' export const runtime = 'nodejs'
export const maxDuration = 60 export const maxDuration = 60
@@ -112,8 +112,8 @@ export async function POST(req: NextRequest) {
// Convert HEIC/HEIF to JPEG so all browsers can display it // Convert HEIC/HEIF to JPEG so all browsers can display it
if (mimeType === 'image/heic' || mimeType === 'image/heif') { if (mimeType === 'image/heic' || mimeType === 'image/heif') {
try { try {
const converted = await sharp(buffer).jpeg({ quality: 90 }).toBuffer() const converted = await convertHeicToJpeg(buffer)
buffer = converted as unknown as Buffer buffer = converted
ext = '.jpg' ext = '.jpg'
mimeType = 'image/jpeg' mimeType = 'image/jpeg'
} catch { } catch {
+15
View File
@@ -0,0 +1,15 @@
// @ts-expect-error no type declarations available
import heicConvert from 'heic-convert'
/**
* Convert HEIC/HEIF buffer to JPEG.
* Uses heic-convert (pure JS) since sharp's prebuilt libvips lacks HEIF support.
*/
export async function convertHeicToJpeg(buffer: Buffer): Promise<Buffer> {
const result = await heicConvert({
buffer,
format: 'JPEG',
quality: 0.9,
})
return Buffer.from(result)
}