Skip to content

Commit 623dd90

Browse files
esmcelroyCopilot
andcommitted
feat: add HEIC/HEIF image support
- Add libheif-js for client-side HEIC decoding via WASM - HEIC files are auto-converted to JPEG on upload - Detection via MIME type, file extension, and magic bytes - Update PhotoUpload to accept .heic/.heif files - Add type declarations for libheif-js - Add unit tests for HEIC detection (6 tests) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3e908ac commit 623dd90

7 files changed

Lines changed: 235 additions & 6 deletions

File tree

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
},
1717
"dependencies": {
1818
"jszip": "^3.10.1",
19+
"libheif-js": "^1.19.8",
1920
"lucide-react": "^1.8.0",
2021
"react": "^19.2.4",
2122
"react-dom": "^19.2.4"

src/App.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { PhotoGrid } from './components/PhotoGrid';
66
import { useLocalStorage } from './hooks/useLocalStorage';
77
import type { UploadedPhoto, PaddingSettings } from './types';
88
import { getImageDimensions, findMaxAspectRatio, padImageToAspectRatio } from './lib/imageUtils';
9+
import { processFilesForHeic } from './lib/heicUtils';
910
import { Download, Trash2, Layers } from 'lucide-react';
1011

1112
const DEFAULT_SETTINGS: PaddingSettings = {
@@ -34,8 +35,14 @@ export default function App() {
3435
const maxAspectRatio = findMaxAspectRatio(photos);
3536

3637
const handlePhotosAdded = useCallback(async (files: File[]) => {
38+
// Convert any HEIC/HEIF files to JPEG first
39+
const { converted, errors } = await processFilesForHeic(files);
40+
if (errors.length > 0) {
41+
console.warn('HEIC conversion errors:', errors);
42+
}
43+
3744
const newPhotos: UploadedPhoto[] = [];
38-
for (const file of files) {
45+
for (const file of converted) {
3946
const dataUrl = await readFileAsDataUrl(file);
4047
const { width, height } = await getImageDimensions(dataUrl);
4148
newPhotos.push({

src/__tests__/heicUtils.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { isHeicFile } from '../lib/heicUtils'
3+
4+
describe('isHeicFile', () => {
5+
it('detects HEIC by MIME type', async () => {
6+
const file = new File([''], 'photo.heic', { type: 'image/heic' })
7+
expect(await isHeicFile(file)).toBe(true)
8+
})
9+
10+
it('detects HEIF by MIME type', async () => {
11+
const file = new File([''], 'photo.heif', { type: 'image/heif' })
12+
expect(await isHeicFile(file)).toBe(true)
13+
})
14+
15+
it('detects HEIC by file extension when MIME type is empty', async () => {
16+
const file = new File([''], 'photo.heic', { type: '' })
17+
expect(await isHeicFile(file)).toBe(true)
18+
})
19+
20+
it('detects HEIF by file extension when MIME type is empty', async () => {
21+
const file = new File([''], 'photo.HEIF', { type: '' })
22+
expect(await isHeicFile(file)).toBe(true)
23+
})
24+
25+
it('returns false for JPEG files', async () => {
26+
const file = new File([''], 'photo.jpg', { type: 'image/jpeg' })
27+
expect(await isHeicFile(file)).toBe(false)
28+
})
29+
30+
it('returns false for PNG files', async () => {
31+
const file = new File([''], 'photo.png', { type: 'image/png' })
32+
expect(await isHeicFile(file)).toBe(false)
33+
})
34+
})

src/components/PhotoUpload.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ interface PhotoUploadProps {
77
}
88

99
const MAX_PHOTOS = 20;
10-
const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
10+
const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/heic', 'image/heif'];
1111

1212
export function PhotoUpload({ onPhotosAdded, currentCount }: PhotoUploadProps) {
1313
const [isDragging, setIsDragging] = useState(false);
@@ -20,10 +20,15 @@ export function PhotoUpload({ onPhotosAdded, currentCount }: PhotoUploadProps) {
2020
};
2121

2222
const handleFiles = useCallback((files: File[]) => {
23-
const validFiles = files.filter(f => ACCEPTED_TYPES.includes(f.type));
23+
const validFiles = files.filter(f => {
24+
if (ACCEPTED_TYPES.includes(f.type)) return true;
25+
// HEIC files may have empty MIME type — check extension
26+
const ext = f.name.split('.').pop()?.toLowerCase();
27+
return ext === 'heic' || ext === 'heif';
28+
});
2429
const invalidCount = files.length - validFiles.length;
2530
if (invalidCount > 0) {
26-
showError(`${invalidCount} file(s) skipped — only JPG, PNG, WebP, and GIF are supported.`);
31+
showError(`${invalidCount} file(s) skipped — only JPG, PNG, WebP, GIF, and HEIC are supported.`);
2732
}
2833
const remaining = MAX_PHOTOS - currentCount;
2934
if (validFiles.length > remaining) {
@@ -72,15 +77,15 @@ export function PhotoUpload({ onPhotosAdded, currentCount }: PhotoUploadProps) {
7277
{isFull ? 'Maximum photos reached' : 'Drop photos here or click to browse'}
7378
</p>
7479
<p className="text-xs text-gray-500 mt-1">
75-
JPG, PNG, WebP, GIF — up to {MAX_PHOTOS} photos ({currentCount}/{MAX_PHOTOS} added)
80+
JPG, PNG, WebP, GIF, HEIC — up to {MAX_PHOTOS} photos ({currentCount}/{MAX_PHOTOS} added)
7681
</p>
7782
</div>
7883
</div>
7984
<input
8085
ref={inputRef}
8186
type="file"
8287
multiple
83-
accept="image/jpeg,image/png,image/webp,image/gif"
88+
accept="image/jpeg,image/png,image/webp,image/gif,image/heic,image/heif,.heic,.heif"
8489
className="hidden"
8590
onChange={onInputChange}
8691
/>

src/lib/heicUtils.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/**
2+
* HEIC/HEIF image detection and conversion utilities.
3+
* Uses libheif-js (WASM) for client-side decoding.
4+
*/
5+
6+
const HEIC_EXTENSIONS = ['.heic', '.heif']
7+
const HEIC_MIME_TYPES = ['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence']
8+
9+
/**
10+
* Detect if a file is HEIC/HEIF by MIME type, extension, or magic bytes.
11+
*/
12+
export async function isHeicFile(file: File): Promise<boolean> {
13+
if (HEIC_MIME_TYPES.includes(file.type.toLowerCase())) {
14+
return true
15+
}
16+
17+
const ext = '.' + file.name.split('.').pop()?.toLowerCase()
18+
if (HEIC_EXTENSIONS.includes(ext)) {
19+
return true
20+
}
21+
22+
// Check magic bytes: ftyp box at offset 4
23+
try {
24+
const slice = file.slice(0, 12)
25+
const buffer = await slice.arrayBuffer()
26+
const header = new Uint8Array(buffer)
27+
if (header.length >= 12) {
28+
const ftyp = String.fromCharCode(header[4], header[5], header[6], header[7])
29+
if (ftyp === 'ftyp') {
30+
const brand = String.fromCharCode(header[8], header[9], header[10], header[11])
31+
if (['heic', 'heix', 'mif1', 'heif'].includes(brand)) {
32+
return true
33+
}
34+
}
35+
}
36+
} catch {
37+
// Can't read file — fall through
38+
}
39+
40+
return false
41+
}
42+
43+
interface HeifImage {
44+
get_width(): number
45+
get_height(): number
46+
display(imageData: ImageData, callback: (result: ImageData) => void): void
47+
}
48+
49+
interface HeifDecoder {
50+
decode(data: Uint8Array): HeifImage[]
51+
}
52+
53+
interface LibHeif {
54+
HeifDecoder: new () => HeifDecoder
55+
}
56+
57+
let libheifInstance: LibHeif | null = null
58+
59+
async function getLibHeif(): Promise<LibHeif> {
60+
if (libheifInstance) return libheifInstance
61+
const module = await import('libheif-js')
62+
libheifInstance = module.default || module
63+
return libheifInstance!
64+
}
65+
66+
/**
67+
* Convert a HEIC/HEIF file to JPEG using libheif-js WASM decoder.
68+
*/
69+
export async function convertHeicToJpeg(file: File, quality = 0.92): Promise<File> {
70+
const libheif = await getLibHeif()
71+
const buffer = await file.arrayBuffer()
72+
const data = new Uint8Array(buffer)
73+
74+
const decoder = new libheif.HeifDecoder()
75+
const images = decoder.decode(data)
76+
77+
if (!images || images.length === 0) {
78+
throw new Error(`Failed to decode HEIC file: ${file.name}`)
79+
}
80+
81+
const image = images[0]
82+
const width = image.get_width()
83+
const height = image.get_height()
84+
85+
const canvas = document.createElement('canvas')
86+
canvas.width = width
87+
canvas.height = height
88+
const ctx = canvas.getContext('2d')
89+
if (!ctx) throw new Error('Could not create canvas context')
90+
91+
const imageData = ctx.createImageData(width, height)
92+
93+
await new Promise<void>((resolve, reject) => {
94+
try {
95+
image.display(imageData, (displayData: ImageData) => {
96+
if (!displayData) {
97+
reject(new Error(`Failed to render HEIC image: ${file.name}`))
98+
return
99+
}
100+
ctx.putImageData(displayData, 0, 0)
101+
resolve()
102+
})
103+
} catch (err) {
104+
reject(err)
105+
}
106+
})
107+
108+
const blob = await new Promise<Blob>((resolve, reject) => {
109+
canvas.toBlob(
110+
(b) => (b ? resolve(b) : reject(new Error('Canvas toBlob failed'))),
111+
'image/jpeg',
112+
quality
113+
)
114+
})
115+
116+
const newName = file.name.replace(/\.(heic|heif)$/i, '.jpg')
117+
return new File([blob], newName, { type: 'image/jpeg' })
118+
}
119+
120+
/**
121+
* Process files, converting any HEIC/HEIF to JPEG. Returns converted files
122+
* and any errors that occurred during conversion.
123+
*/
124+
export async function processFilesForHeic(
125+
files: File[],
126+
): Promise<{ converted: File[]; errors: Array<{ fileName: string; error: string }> }> {
127+
const heicFlags = await Promise.all(files.map(isHeicFile))
128+
const hasHeic = heicFlags.some(Boolean)
129+
130+
if (!hasHeic) {
131+
return { converted: files, errors: [] }
132+
}
133+
134+
const converted: File[] = []
135+
const errors: Array<{ fileName: string; error: string }> = []
136+
137+
for (let i = 0; i < files.length; i++) {
138+
if (!heicFlags[i]) {
139+
converted.push(files[i])
140+
continue
141+
}
142+
143+
try {
144+
converted.push(await convertHeicToJpeg(files[i]))
145+
} catch (err) {
146+
errors.push({
147+
fileName: files[i].name,
148+
error: err instanceof Error ? err.message : 'Unknown conversion error',
149+
})
150+
}
151+
}
152+
153+
return { converted, errors }
154+
}

src/libheif-js.d.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
declare module 'libheif-js' {
2+
interface HeifImage {
3+
get_width(): number
4+
get_height(): number
5+
display(imageData: ImageData, callback: (result: ImageData) => void): void
6+
}
7+
8+
interface HeifDecoder {
9+
decode(data: Uint8Array): HeifImage[]
10+
}
11+
12+
interface LibHeif {
13+
HeifDecoder: new () => HeifDecoder
14+
}
15+
16+
const libheif: LibHeif
17+
export default libheif
18+
}

0 commit comments

Comments
 (0)