diff --git a/package.json b/package.json index f1efb89d..c7458546 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "arweave": "^1.15.7", "chat": "^4.20.2", "exifr": "^7.1.3", + "heic-convert": "^2.1.0", "image-meta": "^0.2.2", "link-preview-js": "^4.0.0", "multiformats": "^13.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94b1f025..204a5390 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: exifr: specifier: ^7.1.3 version: 7.1.3 + heic-convert: + specifier: ^2.1.0 + version: 2.1.0 image-meta: specifier: ^0.2.2 version: 0.2.2 @@ -4615,6 +4618,14 @@ packages: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} + heic-convert@2.1.0: + resolution: {integrity: sha512-1qDuRvEHifTVAj3pFIgkqGgJIr0M3X7cxEPjEp0oG4mo8GFjq99DpCo8Eg3kg17Cy0MTjxpFdoBHOatj7ZVKtg==} + engines: {node: '>=12.0.0'} + + heic-decode@2.1.0: + resolution: {integrity: sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==} + engines: {node: '>=8.0.0'} + hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} @@ -4989,6 +5000,9 @@ packages: jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + jpeg-js@0.4.4: + resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} + js-sha256@0.9.0: resolution: {integrity: sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==} @@ -5108,6 +5122,10 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libheif-js@1.19.8: + resolution: {integrity: sha512-vQJWusIxO7wavpON1dusciL8Go9jsIQ+EUrckauFYAiSTjcmLAsuJh3SszLpvkwPci3JcL41ek2n+LUZGFpPIQ==} + engines: {node: '>=8.0.0'} + libsodium-sumo@0.7.16: resolution: {integrity: sha512-x6atrz2AdXCJg6G709x9W9TTJRI6/0NcL5dD0l5GGVqNE48UJmDsjO4RUWYTeyXXUpg+NXZ2SHECaZnFRYzwGA==} @@ -5905,6 +5923,10 @@ packages: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} + pngjs@6.0.0: + resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==} + engines: {node: '>=12.13.0'} + pony-cause@2.1.11: resolution: {integrity: sha512-M7LhCsdNbNgiLYiP4WjsfLUuFmCfnjdF6jKe2R9NKl4WFN+HZPGHJZ9lnLP7f9ZnKe3U9nuWD0szirmj+migUg==} engines: {node: '>=12.0.0'} @@ -13779,6 +13801,16 @@ snapshots: dependencies: function-bind: 1.1.2 + heic-convert@2.1.0: + dependencies: + heic-decode: 2.1.0 + jpeg-js: 0.4.4 + pngjs: 6.0.0 + + heic-decode@2.1.0: + dependencies: + libheif-js: 1.19.8 + hermes-estree@0.25.1: {} hermes-parser@0.25.1: @@ -14127,6 +14159,8 @@ snapshots: jose@6.2.3: {} + jpeg-js@0.4.4: {} + js-sha256@0.9.0: optional: true @@ -14243,6 +14277,8 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libheif-js@1.19.8: {} + libsodium-sumo@0.7.16: {} libsodium-wrappers-sumo@0.7.16: @@ -15234,6 +15270,8 @@ snapshots: pngjs@5.0.0: {} + pngjs@6.0.0: {} + pony-cause@2.1.11: {} porto@0.2.35(@tanstack/react-query@5.100.9(react@18.3.1))(@types/react@19.2.9)(@wagmi/core@2.22.1(@tanstack/query-core@5.100.9)(@types/react@19.2.9)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.4.3)))(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.4.3))(wagmi@2.19.5(@tanstack/query-core@5.100.9)(@tanstack/react-query@5.100.9(react@18.3.1))(@types/react@19.2.9)(@vercel/blob@2.3.3)(@vercel/functions@3.5.0(@aws-sdk/credential-provider-web-identity@3.972.13))(bufferutil@4.1.0)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@6.0.6)(viem@2.48.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.4.3))(zod@3.25.76)): diff --git a/src/lib/media/__tests__/imageProxyHandler.heic.test.ts b/src/lib/media/__tests__/imageProxyHandler.heic.test.ts new file mode 100644 index 00000000..f49cc5ec --- /dev/null +++ b/src/lib/media/__tests__/imageProxyHandler.heic.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import imageProxyHandler from '@/lib/media/imageProxyHandler'; + +vi.mock('@/lib/arweave/fetchUri'); + +import fetchUri from '@/lib/arweave/fetchUri'; + +const readHeicFixture = (name: string): Buffer => + readFileSync(join(__dirname, '../../telegram/chat/__tests__/fixtures', name)); + +const HEIC_DECODE_TIMEOUT_MS = 30_000; + +describe('imageProxyHandler HEIC integration', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it( + 'converts HEIC to webp blur output via sharp', + async () => { + const heicBuffer = readHeicFixture('apple-styled-photo.heic'); + + vi.mocked(fetchUri).mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(heicBuffer.buffer), + } as Response); + + const result = await imageProxyHandler({ + url: 'ar://test-heic', + width: 16, + height: undefined, + quality: 10, + format: 'webp', + }); + + expect(result.status).toBe(200); + expect(result.headers.get('Content-Type')).toBe('image/webp'); + + const body = Buffer.from(await result.arrayBuffer()); + expect(body.length).toBeGreaterThan(0); + expect(body.toString('ascii', 0, 4)).toBe('RIFF'); + }, + HEIC_DECODE_TIMEOUT_MS + ); +}); diff --git a/src/lib/media/__tests__/isHeicBuffer.test.ts b/src/lib/media/__tests__/isHeicBuffer.test.ts new file mode 100644 index 00000000..11d295ac --- /dev/null +++ b/src/lib/media/__tests__/isHeicBuffer.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import isHeicBuffer from '../isHeicBuffer'; + +const readHeicFixture = (name: string): Buffer => + readFileSync(join(__dirname, '../../telegram/chat/__tests__/fixtures', name)); + +describe('isHeicBuffer', () => { + it('returns true for Apple multi-brand HEIC', () => { + expect(isHeicBuffer(readHeicFixture('apple-styled-photo.heic'))).toBe(true); + }); + + it('returns true for the shelf HEIC fixture', () => { + expect( + isHeicBuffer(readHeicFixture('shelf-christmas-decoration.heic')) + ).toBe(true); + }); + + it('returns false for non-HEIC bytes', () => { + expect(isHeicBuffer(Buffer.from('not-an-image'))).toBe(false); + }); +}); diff --git a/src/lib/media/__tests__/prepareImageBufferForSharp.test.ts b/src/lib/media/__tests__/prepareImageBufferForSharp.test.ts new file mode 100644 index 00000000..405ce7dd --- /dev/null +++ b/src/lib/media/__tests__/prepareImageBufferForSharp.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import prepareImageBufferForSharp from '../prepareImageBufferForSharp'; + +const readHeicFixture = (name: string): Buffer => + readFileSync(join(__dirname, '../../telegram/chat/__tests__/fixtures', name)); + +const HEIC_DECODE_TIMEOUT_MS = 30_000; + +describe('prepareImageBufferForSharp', () => { + it( + 'decodes HEIC fixtures to JPEG for sharp', + async () => { + for (const fixture of [ + 'apple-styled-photo.heic', + 'shelf-christmas-decoration.heic', + ]) { + const output = await prepareImageBufferForSharp( + readHeicFixture(fixture) + ); + expect(output[0]).toBe(0xff); + expect(output[1]).toBe(0xd8); + } + }, + HEIC_DECODE_TIMEOUT_MS + ); + + it('passes through non-HEIC buffers unchanged', async () => { + const input = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + const output = await prepareImageBufferForSharp(input); + expect(output).toBe(input); + }); +}); diff --git a/src/lib/media/decodeHeicToJpegBuffer.ts b/src/lib/media/decodeHeicToJpegBuffer.ts new file mode 100644 index 00000000..b9ca7ee3 --- /dev/null +++ b/src/lib/media/decodeHeicToJpegBuffer.ts @@ -0,0 +1,13 @@ +import convert from 'heic-convert'; + +const decodeHeicToJpegBuffer = async (buffer: Buffer): Promise => { + const output = await convert({ + buffer, + format: 'JPEG', + quality: 1, + }); + + return Buffer.from(output); +}; + +export default decodeHeicToJpegBuffer; diff --git a/src/lib/media/imageProxyHandler.ts b/src/lib/media/imageProxyHandler.ts index c2bacd36..9a51f7e0 100644 --- a/src/lib/media/imageProxyHandler.ts +++ b/src/lib/media/imageProxyHandler.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import sharp from 'sharp'; import fetchUri from '@/lib/arweave/fetchUri'; +import prepareImageBufferForSharp from '@/lib/media/prepareImageBufferForSharp'; const CONTENT_TYPES: Record = { webp: 'image/webp', @@ -33,8 +34,9 @@ const imageProxyHandler = async ({ const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); + const sharpInput = await prepareImageBufferForSharp(buffer); - let pipeline = sharp(buffer).autoOrient(); + let pipeline = sharp(sharpInput).autoOrient(); // Resize if dimensions provided if (width || height) { diff --git a/src/lib/media/isHeicBuffer.ts b/src/lib/media/isHeicBuffer.ts new file mode 100644 index 00000000..265d8c9d --- /dev/null +++ b/src/lib/media/isHeicBuffer.ts @@ -0,0 +1,13 @@ +import readFtypBrands from './readFtypBrands'; + +const HEIC_BRANDS = new Set(['heic', 'heix', 'heim', 'heis', 'mif1']); +const HEIF_BRANDS = new Set(['heif', 'hevc', 'hevx', 'hevm', 'hevs', 'msf1']); + +const isHeicBuffer = (buffer: Buffer): boolean => { + const brands = readFtypBrands(buffer); + return brands.some( + (brand) => HEIC_BRANDS.has(brand) || HEIF_BRANDS.has(brand) + ); +}; + +export default isHeicBuffer; diff --git a/src/lib/media/prepareImageBufferForSharp.ts b/src/lib/media/prepareImageBufferForSharp.ts new file mode 100644 index 00000000..362a2b6a --- /dev/null +++ b/src/lib/media/prepareImageBufferForSharp.ts @@ -0,0 +1,9 @@ +import decodeHeicToJpegBuffer from './decodeHeicToJpegBuffer'; +import isHeicBuffer from './isHeicBuffer'; + +const prepareImageBufferForSharp = async (buffer: Buffer): Promise => { + if (!isHeicBuffer(buffer)) return buffer; + return decodeHeicToJpegBuffer(buffer); +}; + +export default prepareImageBufferForSharp; diff --git a/src/lib/media/readFtypBrands.ts b/src/lib/media/readFtypBrands.ts new file mode 100644 index 00000000..cea075b1 --- /dev/null +++ b/src/lib/media/readFtypBrands.ts @@ -0,0 +1,16 @@ +const readFtypBrands = (buffer: Buffer): string[] => { + if (buffer.length < 16 || buffer.toString('ascii', 4, 8) !== 'ftyp') { + return []; + } + + const ftypLength = buffer.readUInt32BE(0); + if (ftypLength < 16 || ftypLength > buffer.length) return []; + + const brands: string[] = []; + for (let offset = 16; offset + 4 <= ftypLength; offset += 4) { + brands.push(buffer.toString('ascii', offset, offset + 4)); + } + return brands; +}; + +export default readFtypBrands; diff --git a/src/lib/og/__tests__/getBase64Image.heic.test.ts b/src/lib/og/__tests__/getBase64Image.heic.test.ts new file mode 100644 index 00000000..1a13b952 --- /dev/null +++ b/src/lib/og/__tests__/getBase64Image.heic.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import getBase64Image from '@/lib/og/getBase64Image'; + +vi.mock('@/lib/arweave/fetchUri'); + +import fetchUri from '@/lib/arweave/fetchUri'; + +const readHeicFixture = (name: string): Buffer => + readFileSync(join(__dirname, '../../telegram/chat/__tests__/fixtures', name)); + +const HEIC_DECODE_TIMEOUT_MS = 30_000; + +describe('getBase64Image HEIC integration', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it( + 'converts HEIC to a JPEG data URL', + async () => { + const heicBuffer = readHeicFixture('apple-styled-photo.heic'); + + vi.mocked(fetchUri).mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(heicBuffer.buffer), + } as Response); + + const result = await getBase64Image('ar://test-heic'); + + expect(result).toMatch(/^data:image\/jpeg;base64,/); + expect( + Buffer.from(result!.split(',')[1]!, 'base64').length + ).toBeGreaterThan(0); + }, + HEIC_DECODE_TIMEOUT_MS + ); +}); diff --git a/src/lib/og/getBase64Image.ts b/src/lib/og/getBase64Image.ts index 898c32be..3455b26b 100644 --- a/src/lib/og/getBase64Image.ts +++ b/src/lib/og/getBase64Image.ts @@ -1,5 +1,6 @@ import sharp from 'sharp'; import fetchUri from '../arweave/fetchUri'; +import prepareImageBufferForSharp from '@/lib/media/prepareImageBufferForSharp'; const MAX_SIZE = 200; @@ -13,8 +14,10 @@ const getBase64Image = async ( if (!response.ok) return null; const data = await response.arrayBuffer(); + const buffer = Buffer.from(data); + const sharpInput = await prepareImageBufferForSharp(buffer); - const resized = await sharp(Buffer.from(data)) + const resized = await sharp(sharpInput) .resize(MAX_SIZE, MAX_SIZE, { fit: 'cover' }) .jpeg({ quality: 70 }) .toBuffer(); diff --git a/src/types/heic-convert.d.ts b/src/types/heic-convert.d.ts new file mode 100644 index 00000000..1aa33c5b --- /dev/null +++ b/src/types/heic-convert.d.ts @@ -0,0 +1,10 @@ +declare module 'heic-convert' { + type HeicConvertInput = { + buffer: Buffer; + format: 'JPEG' | 'PNG'; + quality?: number; + }; + + const convert: (input: HeicConvertInput) => Promise; + export default convert; +}