diff --git a/astro.config.mjs b/astro.config.mjs index 0a4b054..e8701e7 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -133,6 +133,8 @@ export default defineConfig({ '**/epubjs*.js', '**/jszip*.js', '**/html2canvas*.js', + '**/heic-to*.js', + '**/libheif*.js', 'og/*.png', ], runtimeCaching: [ diff --git a/docs/superpowers/plans/2026-08-13-heic-to-jpg.md b/docs/superpowers/plans/2026-08-13-heic-to-jpg.md new file mode 100644 index 0000000..85070a3 --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-heic-to-jpg.md @@ -0,0 +1,64 @@ +# HEIC → JPG Converter Implementation Plan + +> **For agentic workers:** implement task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Ship a client-side HEIC/HEIF → JPG batch converter to GoodWebTools. + +**Architecture:** Pure lib (`heic.lib.ts`) does detection + filename + decode; thin island (`HeicToJpg.tsx`) does batch UI, quality slider, per-file `ImageResult`, ZIP-all. Registry + SEO + globIgnores wire it in. + +**Tech Stack:** Astro + React island, Vitest, `heic-to` (libheif-wasm), `fflate` via `createZip`. + +## Global Constraints + +- Everything client-side; no server calls. +- Heavy dep dynamic-imported inside the lib; chunk added to `workbox.globIgnores`. +- New tool `status: 'beta'`. +- Bahasa copy uses "tool" loanword, never "alat". +- Commit under the personal noreply identity; no AI-attribution trailers; no absolute machine paths in committed files. + +--- + +### Task 1: Install `heic-to` + +- [ ] `npm install heic-to@^1.5.2 --save` (repo `.npmrc` sets legacy-peer-deps). +- [ ] Confirm it appears in `package.json` dependencies. + +### Task 2: Pure lib `heic.lib.ts` (TDD) + +**Files:** Create `src/tools/image/heic.lib.ts`, Test `src/tools/image/heic.lib.test.ts`. + +**Interfaces produced:** +- `isLikelyHeic(file: { name: string; type: string }): boolean` +- `jpegName(originalName: string): string` +- `heicToJpeg(file: File, quality: number): Promise` + +- [ ] Write failing tests: `isLikelyHeic` true for `photo.heic`, `IMG.HEIF`, `x.heic` with empty type, MIME `image/heic`/`image/heif`; false for `photo.jpg`, `a.png`, `notes.txt` with empty type. `jpegName`: `IMG_1.heic`→`IMG_1.jpg`, `a.HEIC`→`a.jpg`, `noext`→`noext.jpg`, `my.photo.heif`→`my.photo.jpg`. +- [ ] Run — confirm fail (module not found). +- [ ] Implement lib. `heicToJpeg` dynamic-imports `heic-to`; `isLikelyHeic`/`jpegName` pure. +- [ ] Run — confirm pass. + +### Task 3: Island `HeicToJpg.tsx` + +**Files:** Create `src/islands/image/HeicToJpg.tsx`. + +- [ ] Build UI mirroring `ImageConvert.tsx`: `Dropzone multiple`, quality slider, `ProgressBar` (percent = done/total), per-file `ImageResult`, "Download all as ZIP" via `createZip` + `ResultActions`/`downloadService`. i18n TR en+id. Filter non-HEIC via `isLikelyHeic` with an `Alert` note. Per-file try/catch so one failure doesn't abort the batch. SSR-safe. + +### Task 4: Register + SEO + globIgnores + +**Files:** Modify `src/registry/tools.ts`, `src/registry/tool-seo.ts`, `astro.config.mjs`. + +- [ ] Import `ImageDown` in `tools.ts` (if not already) and add the ToolDef. +- [ ] Add EN + ID `image-heic-to-jpg` entries to `tool-seo.ts`. +- [ ] Add `'**/heic-to*.js'`, `'**/libheif*.js'` to `workbox.globIgnores`. + +### Task 5: Verify loop + +- [ ] `npx vitest run` green. +- [ ] `npm run lint` 0 errors. +- [ ] `npm run build` succeeds; `/tools/image-heic-to-jpg` built; no precache-size warning. +- [ ] Hand review: objectURL cleanup in island, reference-identity of derived props, error/empty paths. + +### Task 6: Ship dev → prod + +- [ ] Commit on `feat/heic-to-jpg`, PR → develop, CI green, merge. +- [ ] Promote develop → main (`--admin`), confirm Cloudflare prod build, verify live URL. diff --git a/docs/superpowers/specs/2026-08-13-heic-to-jpg-design.md b/docs/superpowers/specs/2026-08-13-heic-to-jpg-design.md new file mode 100644 index 0000000..d47e0f0 --- /dev/null +++ b/docs/superpowers/specs/2026-08-13-heic-to-jpg-design.md @@ -0,0 +1,78 @@ +# HEIC → JPG Converter — Design + +**Date:** 2026-08-13 +**Category:** Image +**Tool id:** `image-heic-to-jpg` + +## Goal + +Let anyone convert Apple HEIC/HEIF photos to JPG entirely in the browser — no upload, no server. Solves the ubiquitous "every iPhone photo is HEIC and Windows/web apps can't open it" wall. + +## Why it fits GWT + +100% client-side. HEIC is decoded with libheif compiled to wasm (via the `heic-to` package, which inlines the libheif wasm), then re-encoded to JPEG. Nothing leaves the device — consistent with the privacy-first promise. + +## Scope (confirmed with user) + +- **Batch input + ZIP output.** Drop many HEIC files at once; download each JPG individually or all as one ZIP. +- **JPG output with a quality slider** (0.5–1.0, default 0.92). +- Per-file failures surface as a warning without aborting the rest of the batch. + +## Library choice + +**`heic-to` (v1.5.2)** — modern ESM wrapper around libheif-wasm. `heicTo({ blob, type: 'image/jpeg', quality })` decodes HEIC and returns a JPEG `Blob` in one call. The libheif wasm is inlined (base64) in the JS chunk, so there is no separate `.wasm` asset to host under `public/models/`. Dynamic-imported inside the lib so it never bloats the island chunk; its emitted chunk is added to `workbox.globIgnores`. + +Fallback if `heic-to` misbehaves at runtime: `heic2any` (same wasm-inlined approach, older). Decided at build time, not shipped as a runtime toggle. + +## Architecture + +Mirrors the existing `image-convert` tool. + +### Pure lib — `src/tools/image/heic.lib.ts` + +- `isLikelyHeic(file: { name: string; type: string }): boolean` — **pure, unit-tested.** Browsers frequently report an empty or generic MIME type for HEIC, so detection is by extension (`.heic`, `.heif`, case-insensitive) OR by a recognized MIME (`image/heic`, `image/heif`, `image/heic-sequence`, `image/heif-sequence`). +- `jpegName(originalName: string): string` — **pure, unit-tested.** Replaces the final extension with `.jpg`; appends `.jpg` when there is no extension. +- `heicToJpeg(file: File, quality: number): Promise` — thin wrapper. `const { heicTo } = await import('heic-to');` then `return heicTo({ blob: file, type: 'image/jpeg', quality })`. Not unit-tested (needs real wasm decode); covered by manual smoke. + +### Island — `src/islands/image/HeicToJpg.tsx` (default export) + +- `Dropzone` with `multiple` — accepts a batch. Filters kept files through `isLikelyHeic`; files that fail the filter are reported (`Alert`) but ignored. +- Quality slider (range input, 0.5–1.0 step 0.01). +- "Convert" runs each file through `heicToJpeg`, accumulating `{ name, blob }` results and `{ name, message }` errors. Indeterminate work → plain busy line + count (`Converting 3 of 12…`), **not** `ProgressBar` (which is determinate-only). Actually we can show determinate progress since we know the total — use a simple "n of N" text; `ProgressBar` with `percent` is acceptable too. Use `ProgressBar` with `percent = done/total*100`. +- Results: one `ImageResult` per converted file (preview + individual download), plus a "Download all as ZIP" button (`ResultActions` / `downloadService`) built from `createZip` (`src/tools/files/zip.lib.ts`). +- i18n `TR: Record` with `en` + `id`, selected by `lang` prop. Signature `export default function HeicToJpg({ lang = 'en' }: { lang?: Lang })`. +- SSR-safe: no `window`/`document` at module scope. + +### Registry — `src/registry/tools.ts` + +```ts +{ + id: 'image-heic-to-jpg', + name: 'HEIC to JPG', + category: 'Image', + route: '/tools/image-heic-to-jpg', + keywords: ['heic', 'heif', 'jpg', 'jpeg', 'convert', 'iphone', 'photo', 'apple'], + icon: ImageDown, + summary: 'Convert iPhone HEIC/HEIF photos to JPG', + load: () => import('@/islands/image/HeicToJpg'), + status: 'beta' +}, +``` +`ImageDown` imported from `lucide-react` at the top of `tools.ts`. + +### SEO — `src/registry/tool-seo.ts` + +Add `image-heic-to-jpg` to both the EN block (~line 693 area) and the ID block (~line 2200 area). Fields: `title`, `description`, `intro`, `howTo` (string[]), `faqs` (`{q,a}[]`). Keyword targets: "HEIC to JPG", "convert iPhone photos", "open HEIC on Windows", "HEIC viewer". Bahasa uses the "tool" loanword, never "alat". + +### PWA precache — `astro.config.mjs` + +Add to `workbox.globIgnores`: `'**/heic-to*.js'` and `'**/libheif*.js'` (whichever chunk names the decoder emits) so the heavy wasm-carrying chunk stays out of the precache. + +## Testing + +- `src/tools/image/heic.lib.test.ts` — table-driven (`it.each`) over `isLikelyHeic` (extensions, MIME variants, negatives like `.jpg`/`.png`) and `jpegName` (has-ext, no-ext, uppercase ext, dotted names). +- Island covered by build + manual smoke (real HEIC decode can't run in jsdom). + +## Definition of done + +Spec+plan committed · `heic.lib.ts` unit-tested · vitest + lint + build green · new `/tools/image-heic-to-jpg` page builds · merged to develop · promoted to main · Cloudflare prod build green · live URL verified · user told about PWA hard-refresh. diff --git a/package-lock.json b/package-lock.json index 6842ba7..bb74b72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "fflate": "^0.8.3", "gifenc": "^1.0.3", "hash-wasm": "^4.12.0", + "heic-to": "^1.5.2", "highlight.js": "^11.11.1", "html-to-image": "^1.11.13", "html2canvas": "^1.4.1", @@ -13095,6 +13096,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/heic-to": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/heic-to/-/heic-to-1.5.2.tgz", + "integrity": "sha512-8Fns+lZHAWmz5U5IUxDeXKwIf3foBoKNPLxxFY4B0MkLjNuomEIHCoDbDE+x/llFK3NCEO1cu4+n3iUKY+Svmw==", + "license": "LGPL-3.0" + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", diff --git a/package.json b/package.json index f9c2d7e..c355dde 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "fflate": "^0.8.3", "gifenc": "^1.0.3", "hash-wasm": "^4.12.0", + "heic-to": "^1.5.2", "highlight.js": "^11.11.1", "html-to-image": "^1.11.13", "html2canvas": "^1.4.1", diff --git a/src/islands/image/HeicToJpg.tsx b/src/islands/image/HeicToJpg.tsx new file mode 100644 index 0000000..2e9950d --- /dev/null +++ b/src/islands/image/HeicToJpg.tsx @@ -0,0 +1,213 @@ +import { useState } from 'react'; +import { Dropzone } from '@/components/ui/Dropzone'; +import { Button } from '@/components/ui/Button'; +import { Alert } from '@/components/ui/Alert'; +import { ImageResult } from '@/components/ui/ImageResult'; +import { ProgressBar } from '@/components/ui/ProgressBar'; +import { isLikelyHeic, jpegName, heicToJpeg } from '@/tools/image/heic.lib'; +import { createZip } from '@/tools/files/zip.lib'; +import { downloadService } from '@/services/download'; +import type { Lang } from '@/i18n/config'; + +const TR: Record string; + converting: string; + clear: string; + progress: string; + ignored: (n: number) => string; + downloadZip: string; + failedItem: (name: string) => string; + genericFail: string; +}> = { + en: { + drop: 'Drop HEIC photos or click to browse', + sub: 'Convert iPhone .heic / .heif photos to JPG · files never leave your browser', + quality: 'JPG quality', + convert: (n) => (n > 1 ? `Convert ${n} photos to JPG` : 'Convert to JPG'), + converting: 'Converting…', + clear: 'Clear', + progress: 'Converting', + ignored: (n) => `${n} non-HEIC ${n === 1 ? 'file was' : 'files were'} ignored.`, + downloadZip: 'Download all as ZIP', + failedItem: (name) => `Could not convert ${name}`, + genericFail: 'Conversion failed — is this a valid HEIC file?', + }, + id: { + drop: 'Letakkan foto HEIC atau klik untuk memilih', + sub: 'Konversi foto .heic / .heif iPhone ke JPG · file tidak pernah meninggalkan browser Anda', + quality: 'Kualitas JPG', + convert: (n) => (n > 1 ? `Konversi ${n} foto ke JPG` : 'Konversi ke JPG'), + converting: 'Mengonversi…', + clear: 'Bersihkan', + progress: 'Mengonversi', + ignored: (n) => `${n} file non-HEIC diabaikan.`, + downloadZip: 'Unduh semua sebagai ZIP', + failedItem: (name) => `Tidak dapat mengonversi ${name}`, + genericFail: 'Konversi gagal — apakah ini file HEIC yang valid?', + }, +}; + +interface Converted { + name: string; + blob: Blob; + originalSize: number; +} + +interface FailedItem { + name: string; + message: string; +} + +/** Give each output a unique name so a ZIP / download list never collides. */ +function uniqueName(desired: string, used: Set): string { + if (!used.has(desired)) { + used.add(desired); + return desired; + } + const dot = desired.lastIndexOf('.'); + const base = dot === -1 ? desired : desired.slice(0, dot); + const ext = dot === -1 ? '' : desired.slice(dot); + let i = 2; + let candidate = `${base} (${i})${ext}`; + while (used.has(candidate)) { + i += 1; + candidate = `${base} (${i})${ext}`; + } + used.add(candidate); + return candidate; +} + +export default function HeicToJpg({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const [files, setFiles] = useState([]); + const [ignored, setIgnored] = useState(0); + const [quality, setQuality] = useState(92); + const [results, setResults] = useState([]); + const [errors, setErrors] = useState([]); + const [busy, setBusy] = useState(false); + const [done, setDone] = useState(0); + + const onDrop = (dropped: File[]) => { + const heic = dropped.filter(isLikelyHeic); + setFiles(heic); + setIgnored(dropped.length - heic.length); + setResults([]); + setErrors([]); + setDone(0); + }; + + const run = async () => { + if (files.length === 0) return; + setBusy(true); + setResults([]); + setErrors([]); + setDone(0); + const used = new Set(); + const converted: Converted[] = []; + const failed: FailedItem[] = []; + for (const file of files) { + try { + const blob = await heicToJpeg(file, quality / 100); + converted.push({ + name: uniqueName(jpegName(file.name), used), + blob, + originalSize: file.size, + }); + setResults([...converted]); + } catch (e) { + failed.push({ + name: file.name, + message: e instanceof Error ? e.message : t.genericFail, + }); + setErrors([...failed]); + } finally { + setDone(d => d + 1); + } + } + setBusy(false); + }; + + const clear = () => { + setFiles([]); + setIgnored(0); + setResults([]); + setErrors([]); + setDone(0); + }; + + const downloadZip = async () => { + const entries = await Promise.all( + results.map(async r => ({ name: r.name, data: new Uint8Array(await r.blob.arrayBuffer()) })), + ); + const zip = createZip(entries); + await downloadService.download(new Blob([zip], { type: 'application/zip' }), 'heic-to-jpg.zip'); + }; + + return ( +
+ +
+

{t.drop}

+

{t.sub}

+
+
+ + {ignored > 0 &&

{t.ignored(ignored)}

} + + {files.length > 0 && ( +

+ {files.length} {files.length === 1 ? 'file' : 'files'} +

+ )} + + + +
+ + +
+ + {busy && files.length > 0 && ( + + )} + + {errors.map(err => ( + + {t.failedItem(err.name)}: {err.message} + + ))} + + {results.length > 1 && ( + + )} + +
+ {results.map(r => ( + + ))} +
+
+ ); +} diff --git a/src/registry/tool-seo.ts b/src/registry/tool-seo.ts index b482347..80ea455 100644 --- a/src/registry/tool-seo.ts +++ b/src/registry/tool-seo.ts @@ -707,6 +707,24 @@ const en: Record = { { q: 'Does the converter work offline?', a: 'Yes. GoodWebTools is a PWA, so once loaded it keeps converting images with no internet connection.' }, ], }, + 'image-heic-to-jpg': { + title: 'Free HEIC to JPG Converter — Open iPhone Photos Anywhere', + description: 'Convert Apple HEIC/HEIF photos to JPG right in your browser. Batch-convert a whole camera roll and download as a ZIP. Nothing is uploaded — everything runs on your device.', + intro: 'Every iPhone shoots HEIC by default, and most Windows apps, websites and older software still cannot open it. This free HEIC to JPG converter decodes your .heic and .heif photos with libheif compiled to WebAssembly and re-encodes them as universally-supported JPG. Drop a whole batch, pick a quality, and download each JPG or all of them as one ZIP — your photos never leave your browser.', + howTo: [ + 'Drop one or many .heic / .heif photos, or click to browse for them.', + 'Drag the JPG quality slider to balance file size against sharpness (92% is a good default).', + 'Click Convert — every photo is decoded and re-encoded on your device.', + 'Download each JPG individually, or grab the whole batch as a single ZIP.', + ], + faqs: [ + { q: 'What is a HEIC file and why can\'t I open it?', a: 'HEIC (High Efficiency Image Container) is the format iPhones and iPads use to store photos more compactly than JPG. Many Windows programs, websites and older apps do not support it, which is why converting to JPG makes the photo open everywhere.' }, + { q: 'Are my photos uploaded to a server?', a: 'No. Decoding and conversion run entirely in your browser using libheif compiled to WebAssembly, so your photos stay on your device and are never uploaded.' }, + { q: 'Can I convert many HEIC photos at once?', a: 'Yes. Drop as many .heic or .heif files as you like, convert them in one go, and download them individually or all together as a single ZIP archive.' }, + { q: 'Does it work on Windows, Mac, and Android?', a: 'Yes. It runs in any modern browser on any operating system — no iPhone or Apple software required to open the photos.' }, + { q: 'Will converting lose photo quality?', a: 'JPG is a lossy format, but at a high quality setting the difference is hard to see. Use the quality slider to keep more detail, or lower it for smaller files.' }, + ], + }, 'image-viewer': { title: 'Free Image Viewer Tool — Dimensions & EXIF', description: 'A free online tool to view any image and read its dimensions, type, EXIF orientation, GPS and ICO sizes. Runs in your browser — images are never uploaded.', @@ -2214,6 +2232,24 @@ const id: Record = { { q: 'Apakah konverter bekerja offline?', a: 'Ya. GoodWebTools adalah PWA, jadi setelah dimuat, konversi gambar tetap berjalan tanpa koneksi internet.' }, ], }, + 'image-heic-to-jpg': { + title: 'Konverter HEIC ke JPG Gratis — Buka Foto iPhone di Mana Saja', + description: 'Konversi foto HEIC/HEIF Apple ke JPG langsung di browser Anda. Konversi sekaligus satu album kamera dan unduh sebagai ZIP. Tidak ada yang diunggah — semuanya berjalan di perangkat Anda.', + intro: 'Setiap iPhone memotret dalam format HEIC secara bawaan, dan sebagian besar aplikasi Windows, situs web, serta perangkat lunak lama masih belum bisa membukanya. Tool konverter HEIC ke JPG gratis ini mendekode foto .heic dan .heif Anda dengan libheif yang dikompilasi ke WebAssembly, lalu mengodenya ulang sebagai JPG yang didukung di mana saja. Jatuhkan banyak file sekaligus, pilih kualitas, dan unduh tiap JPG atau semuanya sebagai satu ZIP — foto Anda tidak pernah meninggalkan browser.', + howTo: [ + 'Jatuhkan satu atau banyak foto .heic / .heif, atau klik untuk menjelajah.', + 'Geser penggeser kualitas JPG untuk menyeimbangkan ukuran berkas dan ketajaman (92% adalah default yang baik).', + 'Klik Convert — setiap foto didekode dan dikodekan ulang di perangkat Anda.', + 'Unduh tiap JPG satu per satu, atau ambil seluruh batch sebagai satu file ZIP.', + ], + faqs: [ + { q: 'Apa itu file HEIC dan mengapa saya tidak bisa membukanya?', a: 'HEIC (High Efficiency Image Container) adalah format yang dipakai iPhone dan iPad untuk menyimpan foto lebih ringkas daripada JPG. Banyak program Windows, situs web, dan aplikasi lama tidak mendukungnya, sehingga mengonversi ke JPG membuat foto bisa dibuka di mana saja.' }, + { q: 'Apakah foto saya diunggah ke server?', a: 'Tidak. Pendekodean dan konversi berjalan sepenuhnya di browser Anda menggunakan libheif yang dikompilasi ke WebAssembly, jadi foto Anda tetap di perangkat dan tidak pernah diunggah.' }, + { q: 'Bisakah saya mengonversi banyak foto HEIC sekaligus?', a: 'Ya. Jatuhkan sebanyak mungkin file .heic atau .heif, konversi sekaligus, dan unduh satu per satu atau semuanya sebagai satu arsip ZIP.' }, + { q: 'Apakah bekerja di Windows, Mac, dan Android?', a: 'Ya. Berjalan di browser modern mana pun di sistem operasi apa pun — tanpa perlu iPhone atau perangkat lunak Apple untuk membuka foto.' }, + { q: 'Apakah konversi menurunkan kualitas foto?', a: 'JPG adalah format lossy, tetapi pada pengaturan kualitas tinggi perbedaannya sulit terlihat. Gunakan penggeser kualitas untuk mempertahankan lebih banyak detail, atau turunkan untuk berkas yang lebih kecil.' }, + ], + }, 'image-viewer': { title: 'Tool Penampil Gambar Gratis — Dimensi & EXIF', description: 'Tool online gratis untuk menampilkan gambar apa pun serta membaca dimensi, tipe, orientasi EXIF, GPS, dan ukuran ICO-nya. Berjalan di browser Anda — gambar tidak pernah diunggah.', diff --git a/src/registry/tools.ts b/src/registry/tools.ts index 35863c9..8cb213d 100644 --- a/src/registry/tools.ts +++ b/src/registry/tools.ts @@ -476,6 +476,17 @@ export const tools: ToolDef[] = [ load: () => import('@/islands/image/ImageConvert'), status: 'stable' }, + { + id: 'image-heic-to-jpg', + name: 'HEIC to JPG', + category: 'Image', + route: '/tools/image-heic-to-jpg', + keywords: ['heic', 'heif', 'jpg', 'jpeg', 'convert', 'iphone', 'photo', 'apple'], + icon: ImageDown, + summary: 'Convert iPhone HEIC/HEIF photos to JPG', + load: () => import('@/islands/image/HeicToJpg'), + status: 'beta' + }, { id: 'image-viewer', name: 'Image Viewer & Metadata', diff --git a/src/tools/image/heic.lib.test.ts b/src/tools/image/heic.lib.test.ts new file mode 100644 index 0000000..659bdc6 --- /dev/null +++ b/src/tools/image/heic.lib.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { isLikelyHeic, jpegName } from './heic.lib'; + +describe('isLikelyHeic', () => { + it.each([ + // Extension-based (browsers often report an empty MIME type for HEIC). + [{ name: 'photo.heic', type: '' }, true], + [{ name: 'IMG_0001.HEIF', type: '' }, true], + [{ name: 'vacation.Heic', type: '' }, true], + // MIME-based (extension missing or generic). + [{ name: 'blob', type: 'image/heic' }, true], + [{ name: 'blob', type: 'image/heif' }, true], + [{ name: 'blob', type: 'image/heic-sequence' }, true], + [{ name: 'blob', type: 'image/heif-sequence' }, true], + // Negatives. + [{ name: 'photo.jpg', type: 'image/jpeg' }, false], + [{ name: 'photo.png', type: 'image/png' }, false], + [{ name: 'notes.txt', type: '' }, false], + [{ name: 'archive.heicx', type: '' }, false], + ])('classifies %o as %s', (file, expected) => { + expect(isLikelyHeic(file)).toBe(expected); + }); +}); + +describe('jpegName', () => { + it.each([ + ['IMG_1234.heic', 'IMG_1234.jpg'], + ['vacation.HEIC', 'vacation.jpg'], + ['noext', 'noext.jpg'], + ['my.photo.heif', 'my.photo.jpg'], + ['already.jpg', 'already.jpg'], + ['', 'image.jpg'], + ])('%s → %s', (input, expected) => { + expect(jpegName(input)).toBe(expected); + }); +}); diff --git a/src/tools/image/heic.lib.ts b/src/tools/image/heic.lib.ts new file mode 100644 index 0000000..5f88ffe --- /dev/null +++ b/src/tools/image/heic.lib.ts @@ -0,0 +1,44 @@ +/** + * HEIC/HEIF → JPEG conversion, entirely in the browser. + * + * Decoding uses libheif compiled to wasm (via the `heic-to` package, which + * inlines the wasm), so nothing is ever uploaded. The heavy decoder is + * dynamic-imported inside `heicToJpeg` to keep the island chunk small. + */ + +const HEIC_EXT = /\.(heic|heif)$/i; +const HEIC_MIME = new Set([ + 'image/heic', + 'image/heif', + 'image/heic-sequence', + 'image/heif-sequence', +]); + +/** + * Best-effort, synchronous check that a file is HEIC/HEIF — used to filter a + * dropped batch before decoding. Browsers frequently report an empty or + * generic MIME type for HEIC, so extension is the primary signal, with the + * MIME type as a fallback for files that arrive without a `.heic`/`.heif` name. + */ +export function isLikelyHeic(file: { name: string; type: string }): boolean { + return HEIC_EXT.test(file.name) || HEIC_MIME.has(file.type.toLowerCase()); +} + +/** Swap a filename's extension for `.jpg` (append it when there is none). */ +export function jpegName(originalName: string): string { + if (!originalName) return 'image.jpg'; + const base = originalName.includes('.') + ? originalName.replace(/\.[^.]+$/, '') + : originalName; + return `${base}.jpg`; +} + +/** + * Decode a HEIC/HEIF file and re-encode it as JPEG at the given quality + * (0–1). Throws if the file cannot be decoded (e.g. it is not really HEIC). + */ +export async function heicToJpeg(file: File, quality: number): Promise { + const { heicTo } = await import('heic-to'); + const q = Math.min(Math.max(quality, 0), 1); + return heicTo({ blob: file, type: 'image/jpeg', quality: q }); +}