Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ export default defineConfig({
'**/epubjs*.js',
'**/jszip*.js',
'**/html2canvas*.js',
'**/heic-to*.js',
'**/libheif*.js',
'og/*.png',
],
runtimeCaching: [
Expand Down
64 changes: 64 additions & 0 deletions docs/superpowers/plans/2026-08-13-heic-to-jpg.md
Original file line number Diff line number Diff line change
@@ -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<Blob>`

- [ ] 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.
78 changes: 78 additions & 0 deletions docs/superpowers/specs/2026-08-13-heic-to-jpg-design.md
Original file line number Diff line number Diff line change
@@ -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<Blob>` — 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<Lang, {...}>` 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.
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
213 changes: 213 additions & 0 deletions src/islands/image/HeicToJpg.tsx
Original file line number Diff line number Diff line change
@@ -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<Lang, {
drop: string;
sub: string;
quality: string;
convert: (n: number) => 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>): 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<File[]>([]);
const [ignored, setIgnored] = useState(0);
const [quality, setQuality] = useState(92);
const [results, setResults] = useState<Converted[]>([]);
const [errors, setErrors] = useState<FailedItem[]>([]);
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<string>();
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 (
<div className="space-y-4">
<Dropzone onDrop={onDrop} accept=".heic,.heif,image/heic,image/heif" multiple>
<div className="space-y-1">
<p className="text-lg font-bold">{t.drop}</p>
<p className="text-sm text-muted-foreground">{t.sub}</p>
</div>
</Dropzone>

{ignored > 0 && <p className="text-sm text-muted-foreground">{t.ignored(ignored)}</p>}

{files.length > 0 && (
<p className="text-sm font-bold text-foreground">
{files.length} {files.length === 1 ? 'file' : 'files'}
</p>
)}

<label className="block space-y-1.5">
<span className="flex justify-between text-sm font-bold uppercase tracking-wide text-muted-foreground">
<span>{t.quality}</span>
<span>{quality}%</span>
</span>
<input
type="range"
min={50}
max={100}
value={quality}
onChange={e => setQuality(Number(e.target.value))}
className="w-full accent-accent"
/>
</label>

<div className="flex flex-wrap gap-2">
<Button onClick={run} disabled={files.length === 0 || busy}>
{busy ? t.converting : t.convert(files.length)}
</Button>
<Button variant="ghost" onClick={clear} disabled={busy}>
{t.clear}
</Button>
</div>

{busy && files.length > 0 && (
<ProgressBar percent={(done / files.length) * 100} label={`${t.progress} ${done}/${files.length}`} />
)}

{errors.map(err => (
<Alert key={err.name} variant="error">
{t.failedItem(err.name)}: {err.message}
</Alert>
))}

{results.length > 1 && (
<Button onClick={downloadZip} disabled={busy}>
{t.downloadZip} ({results.length})
</Button>
)}

<div className="space-y-6">
{results.map(r => (
<ImageResult key={r.name} blob={r.blob} filename={r.name} originalSize={r.originalSize} />
))}
</div>
</div>
);
}
Loading
Loading