Skip to content
Open
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
89 changes: 89 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# CLAUDE.md

Project-level guidance for working in this repo. Read on every session.

## What this is

A pure-Go HEIF/HEIC image decoder published as `github.com/gen2brain/heic`. Implements `image/heic.Decode` and `heic.DecodeConfig` for the standard library's `image` package, and also exposes direct functions.

Two independent backends:

- **WASM path** (`decode.go`): a bundled libheif + libde265 compiled to WebAssembly (`lib/heif.wasm.gz`, embedded via `//go:embed`), run through the `wazero` runtime. Works on every platform and every GOARCH. Zero native dependencies.
- **Dynamic path** (`decode_dynamic.go`): loads the system `libheif` at runtime via `purego`, calls the C API directly. Preferred when available — faster, no WASM interpreter overhead. Enabled only on Linux and macOS (Windows is disabled, see upstream issue #11) and only on 64-bit non-MIPS archs (see the build tag).

`heic.go` dispatches each public function to the appropriate backend at runtime based on a `dynamic` bool set at package init. Callers can force WASM with `ForceWasmMode = true`.

## Build & test

```bash
# Full suite. WASM tests always run. Dynamic tests self-skip if libheif is absent.
go test ./...

# Target one backend:
go test -run 'Dynamic' ./... # requires libheif-dev installed
go test -v -run 'TestDecode$' ./...
```

On Ubuntu, install libheif for the dynamic path:
```bash
sudo apt install -y libheif1 libheif-dev libheif-examples
```

## Rebuilding `heif.wasm.gz`

Required whenever `lib/heif.c` or `lib/Makefile` changes. Needs the WASI SDK at `/opt/wasi-sdk`.

One-time toolchain install:
```bash
curl -LO https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-22/wasi-sdk-22.0-linux.tar.gz
sudo tar -xzf wasi-sdk-22.0-linux.tar.gz -C /opt
sudo mv /opt/wasi-sdk-22.0 /opt/wasi-sdk
```

Rebuild loop:
```bash
cd lib
make clean
make
gzip -9 -f heif.wasm # produces heif.wasm.gz that //go:embed picks up
cd ..
go test ./...
```

The Makefile clones libheif and libde265 at pinned versions on first build (`LIBHEIF_VERSION`, `LIBDE265_VERSION` at the top). First build is slow (~5 min); subsequent builds are fast. `make clean` removes the cloned sources too — use sparingly.

## File map

- `heic.go` — public API (`Decode`, `DecodeConfig`, `ForceWasmMode`, `Init`, `Dynamic()`), error sentinels, shared constants mirroring `libheif/heif.h` enums, `init()` registers the format with `image.RegisterFormat`.
- `decode.go` — WASM backend. `initialize()` builds the `wazero.Runtime` once, `decode()` allocates WASM memory, runs the exported `decode` function twice (probe, then full), reads planes back into Go `image.Image` values.
- `decode_dynamic.go` — purego backend. `init()` probes the system `libheif`, binds symbols. Behind a build tag: `(linux || darwin || windows) && !(nodynamic || arm || 386 || mips || mipsle)`.
- `purego_{darwin,unix,windows,other}.go` — OS-specific `loadLibrary()` implementations that locate `libheif.dylib` / `libheif.so` / `libheif.dll`. The `_other.go` file is the fallback for disabled archs/OS combinations and stubs out `decodeDynamic`.
- `lib/heif.c` — thin C shim. **Only** exports a `decode` function that wraps the entire decode pipeline. libheif internals are statically linked and dead-code-eliminated to unreferenced symbols.
- `lib/Makefile` — WASM build. The `-Wl,--export=<name>` lines control which symbols survive linking. Adding a new C entry point requires adding a matching `--export` line here.
- `decode_test.go` — tests for both backends. Dynamic tests gate through `requireDynamic(t)` (skips when libheif is absent, fails in CI).
- `testdata/*.heic` — small test fixtures (10-bit, 8-bit, 12-bit, grayscale variants).

## Conventions

- **C and WASM are locked together.** If you change `lib/heif.c` or `lib/Makefile`, rebuild `heif.wasm.gz` in the same commit. Don't leave them out of sync.
- **Any new C export needs two edits**: the function in `heif.c` and the `-Wl,--export=<name>` line in the Makefile. Without the export line, `wasm-ld` will drop it.
- **The two backends must produce the same `image.Image` shape** for equivalent inputs. The existing code confirms this — the YCbCr subsample ratio, the Gray/RGBA/NRGBA branch selection, the stride calculation all mirror each other. When adding a new decode variant, keep this parity.
- **Don't mock libheif in tests.** Use real test fixtures in `testdata/` and the real library. The `requireDynamic` gate handles libheif-absent environments.
- **`image.RegisterFormat` is called exactly once** in `heic.go`'s `init()` for the generic `Decode` / `DecodeConfig` pair. Don't register additional decoders for specialized variants (thumbnails, auxiliary images, etc.) — those are opt-in API calls.
- **Errors at package boundaries** are typed sentinels (`ErrMemRead`, `ErrMemWrite`, `ErrDecode`). Wrap with `fmt.Errorf("%s: %w", ...)` inside functions; return the bare sentinel to callers when appropriate so they can `errors.Is` against it.
- **Windows GUI stdout/stderr handling**: the WASM module writes to `os.Stdout`/`os.Stderr` by default, which crashes Windows GUI apps with no console. `isWindowsGUI()` in `decode.go` detects this via the PE subsystem field and redirects to `io.Discard`. Don't remove this branch.

## Platform gotchas

- **Windows + dynamic = disabled.** Don't spend time debugging `purego` on Windows here. The WASM path is the only Windows path.
- **32-bit builds use WASM only.** The build tag on `decode_dynamic.go` excludes `386`, `arm`, `mips`, `mipsle`. This was fixed in PR #12 — don't reintroduce dynamic loading on these archs without reading that thread.
- **Wazero is single-threaded per `wazero.Runtime`**, but the runtime is shared across calls via the `initOnce` guard. Each `Decode` call instantiates a fresh module (`rt.InstantiateModule`) — this is fine but not free; if you're optimizing, the module pool is the place.

## In-flight work

If `PLAN.md` is present at the repo root, it describes a specific task currently in progress. Read it before starting work if it exists; it overrides defaults for the duration of that task.

## Upstream

This is a fork intended to PR changes back to `github.com/gen2brain/heic`. Keep diffs focused, match the existing code style, and rebuild `heif.wasm.gz` in the same commit as any C/Makefile changes.

185 changes: 185 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
# `DecodeThumbnail` implementation plan — gen2brain/heic fork

## Context

Add `DecodeThumbnail` and `DecodeThumbnailConfig` to this fork of `github.com/gen2brain/heic`. The use case is bulk iPhone HEIC processing in a Wails app: iPhone HEIF files embed a 416×312 HEVC thumbnail that decodes ~100× faster than the 5712×4284 primary. This work is intended for upstream PR, so both the bundled-WASM path and the dynamic-libheif path must be implemented and tested.

Repo: `https://github.com/Rosca75/heic` (fork of `gen2brain/heic`).

## Decisions already made (do not re-litigate)

- **Public API:** two functions, `DecodeThumbnail(r io.Reader) (image.Image, error)` and `DecodeThumbnailConfig(r io.Reader) (image.Config, error)`. Same dispatch pattern as `Decode`/`DecodeConfig` in `heic.go`.
- **Missing thumbnail:** return a new sentinel `ErrNoThumbnail = errors.New("heic: no thumbnail")`. Do not fall back to primary decode.
- **Multiple thumbnails:** take the first-found (`heif_image_handle_get_list_of_thumbnail_IDs` with `count=1`).
- **Upstream PR-ready:** implement both WASM and dynamic paths, match upstream code style, update tests in `decode_test.go`.
- **Do not** register `DecodeThumbnail` with `image.RegisterFormat` — it's opt-in, not a generic HEIF decoder.

## Implementation, in order

### Step 1 — C wrapper in `lib/heif.c`

Add a `decode_thumbnail` function next to `decode`. It should:

1. Same filetype check + context alloc + `heif_context_read_from_memory_without_copy` + `heif_context_get_primary_image_handle` as `decode`.
2. Call `heif_image_handle_get_number_of_thumbnails(primary)`. If 0, free resources and **return 2** (sentinel for "no thumbnail").
3. Call `heif_image_handle_get_list_of_thumbnail_IDs(primary, &id, 1)` to get the first thumbnail ID.
4. Call `heif_image_handle_get_thumbnail(primary, id, &thumb_handle)`. On error, return 0.
5. From there, mirror the existing `decode` body but operating on `thumb_handle` instead of the primary handle. `config_only` branch returns early after filling dimensions/colorspace.
6. Return values: **0 = failure, 1 = success, 2 = no thumbnail.** Document this in a comment.

Include header: `#include <libheif/heif.h>` is already there.

Declaration to add near the top (match style of the existing `decode` declaration):

```c
int decode_thumbnail(uint8_t *heic_in, int heic_in_size, int config_only, uint32_t *width, uint32_t *height,
uint32_t *colorspace, uint32_t *chroma, uint32_t *is_premultiplied, uint8_t *out);
```

Factor shared logic out of `decode` and `decode_thumbnail` into a static helper if it reads cleaner — but don't over-engineer; duplication is acceptable here given the file's scope.

### Step 2 — Export it from the WASM build

Edit `lib/Makefile`, add one line to the linker flags (around line 96):

```makefile
-Wl,--export=decode_thumbnail \
```

### Step 3 — Rebuild `heif.wasm.gz`

```bash
cd lib
make clean
make # produces heif.wasm
gzip -9 -f heif.wasm # produces heif.wasm.gz
ls -la heif.wasm.gz
cd ..
```

The `//go:embed lib/heif.wasm.gz` in `decode.go:21` will pick up the new file automatically on next `go build`.

### Step 4 — WASM-path Go wrapper in `decode.go`

Add `decodeThumbnail(r io.Reader, configOnly bool) (image.Image, image.Config, error)`. Structure:

- Copy the body of `decode` (decode.go:24–231) as the starting point.
- Change the `mod.ExportedFunction("decode")` lookup to `"decode_thumbnail"`.
- After the first `_decode.Call` (the config/probe call), check the return value:
- `res[0] == 0` → `ErrDecode` (same as today).
- `res[0] == 2` → `ErrNoThumbnail` (new sentinel, defined in `heic.go`).
- `res[0] == 1` → continue as normal.
- Apply the same check after the second `_decode.Call` (the full decode).
- Everything else — memory layout, plane copying, `image.YCbCr`/`Gray`/`RGBA`/`NRGBA` assembly — is identical.

Don't duplicate the 200-line function body wholesale if you can avoid it: a shared helper that takes the export name as an argument, or a `thumbnail bool` parameter, will keep the diff small.

### Step 5 — Dynamic-path Go wrapper in `decode_dynamic.go`

Add three purego bindings in `init()` (decode_dynamic.go:223–235), following the existing `purego.RegisterLibFunc` pattern:

```go
purego.RegisterLibFunc(&_heifImageHandleGetNumberOfThumbnails, libheif, "heif_image_handle_get_number_of_thumbnails")
purego.RegisterLibFunc(&_heifImageHandleGetListOfThumbnailIDs, libheif, "heif_image_handle_get_list_of_thumbnail_IDs")
purego.RegisterLibFunc(&_heifImageHandleGetThumbnail, libheif, "heif_image_handle_get_thumbnail")
```

Add the function-pointer vars (decode_dynamic.go:248–265 area) with signatures derived from `libheif/heif.h`:

```go
_heifImageHandleGetNumberOfThumbnails func(*heifImageHandle) int
_heifImageHandleGetListOfThumbnailIDs func(*heifImageHandle, *uint32, int) int
_heifImageHandleGetThumbnail func(*heifImageHandle, uint32, **heifImageHandle) uintptr
```

(`heif_item_id` is a `uint32_t` typedef in libheif.)

Add the Go-side wrappers (mirror the pattern at decode_dynamic.go:267–339):

```go
func heifImageHandleGetNumberOfThumbnails(h *heifImageHandle) int { ... }
func heifImageHandleGetListOfThumbnailIDs(h *heifImageHandle, ids []uint32) int { ... }
func heifImageHandleGetThumbnail(h *heifImageHandle, id uint32, out **heifImageHandle) heifError { ... }
```

Add `decodeThumbnailDynamic(r io.Reader, configOnly bool) (image.Image, image.Config, error)`. Mirror `decodeDynamic` (decode_dynamic.go:16–187), but after obtaining the primary `handle`:

1. `n := heifImageHandleGetNumberOfThumbnails(handle)`. If `n == 0`, release the handle and return `ErrNoThumbnail`.
2. `ids := make([]uint32, 1); heifImageHandleGetListOfThumbnailIDs(handle, ids)`.
3. `var thumb *heifImageHandle; e := heifImageHandleGetThumbnail(handle, ids[0], &thumb)`. Check `e.Code`.
4. `defer heifImageHandleRelease(thumb)`, then continue with `thumb` as the working handle for all subsequent calls (`heifImageHandleGetWidth(thumb)`, `heifImageHandleGetPreferredDecodingColorspace(thumb, ...)`, `heifDecodeImage(thumb, ...)`, etc.).

The primary handle should also still be released (the existing `defer heifImageHandleRelease(handle)` stays).

### Step 6 — Public API in `heic.go`

Add:

```go
// ErrNoThumbnail is returned by DecodeThumbnail / DecodeThumbnailConfig when
// the HEIF file contains no embedded thumbnail.
var ErrNoThumbnail = errors.New("heic: no thumbnail")
```

Add two public functions modeled on `Decode` (heic.go:17–35) and `DecodeConfig` (heic.go:37–55). Same `if dynamic && !ForceWasmMode` dispatch to `decodeThumbnailDynamic` / `decodeThumbnail`.

Do **not** touch the `init()` at heic.go:133 — do not add `image.RegisterFormat` for thumbnails.

### Step 7 — Tests in `decode_test.go`

First, determine whether any existing `testdata/*.heic` file has an embedded thumbnail:

```bash
for f in testdata/*.heic; do
echo "=== $f ==="
heif-info "$f" | grep -i thumb
done
```

If one of them does, reuse it. If not (likely — these look like minimal test fixtures), generate a small test file with a thumbnail:

```bash
# Take any existing testdata image, round-trip through heif-enc with a thumbnail.
# Or use a small public-domain JPEG:
heif-enc --thumb 160 -o testdata/thumb.heic <some-input>
```

Keep the file small (ideally <50 KB). Add an `//go:embed testdata/thumb.heic` line matching the existing pattern at decode_test.go:18–28.

Add four tests mirroring the existing style:

- `TestDecodeThumbnail` — calls `decodeThumbnail(bytes.NewReader(testThumb), false)`, JPEG-encodes the result like `TestDecode`.
- `TestDecodeThumbnailConfig` — calls `decodeThumbnail(..., true)`, asserts non-zero `Width` and `Height`.
- `TestDecodeThumbnailDynamic` — same but through `decodeThumbnailDynamic`, gated by `requireDynamic(t)` (decode_test.go:102).
- `TestDecodeThumbnailMissing` — feed `testHeic` (which has no thumbnail) to `decodeThumbnail` and assert `errors.Is(err, ErrNoThumbnail)`. Do the same for the dynamic path.

## Verification

```bash
# Full unit tests (WASM path)
go test ./...

# Dynamic path (libheif installed via apt above)
go test -run Dynamic ./...

# Manual smoke test against an iPhone HEIC if available
# (Write a 10-line main.go that opens the file, calls DecodeThumbnail,
# and writes the result to thumb.jpg. Verify visually.)
```

Both paths must produce an identical-shape `image.Image` (YCbCr 4:2:0 for iPhone thumbnails, in practice) with dimensions matching what `heif-info` reports for the file.

## PR preparation

1. Squash/tidy commits. Upstream commit messages are terse — match the style of recent commits (`git log --oneline -20`).
2. Update `README.md` if it enumerates supported functions.
3. Open PR against `gen2brain/heic:main`. Title suggestion: `Add DecodeThumbnail and DecodeThumbnailConfig`. Body: summarize API additions, note WASM module was rebuilt (wasi-sdk version), confirm both paths are tested.
4. Expect upstream review to push back on any of: duplicated code between `decode`/`decodeThumbnail`, the sentinel-return-code convention in C, purego signature nits. Be prepared to iterate.

## Risks and gotchas

- **WASI SDK version drift:** the rebuilt `heif.wasm.gz` binary will differ from the committed one even if the C source is identical, because different toolchain versions produce different codegen. If the PR diff shows a huge unexplained binary change, note the wasi-sdk version used in the PR description.
- **libheif version on the Ubuntu box:** the dynamic path has a version gate at decode_dynamic.go:62 (`versionMajor == 1 && versionMinor >= 17`). `heif_image_handle_get_list_of_thumbnail_IDs` has been in libheif since forever; no similar gate needed. But verify with `pkg-config --modversion libheif`.
- **`heif_error` struct return via `uintptr`:** the existing code at decode_dynamic.go:290 does `*(*heifError)(unsafe.Pointer(&ret))` on a `uintptr`. This is how upstream does it — follow the pattern; don't try to "fix" it.
- **Windows dynamic path is disabled** (decode_dynamic.go:190). Don't waste time testing the dynamic variant on Windows. The WASM path covers Windows.

18 changes: 16 additions & 2 deletions decode.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,21 @@ import (
var heifWasm []byte

func decode(r io.Reader, configOnly bool) (image.Image, image.Config, error) {
return decodeWASM(r, configOnly, "decode", false)
}

func decodeThumbnail(r io.Reader, configOnly bool) (image.Image, image.Config, error) {
return decodeWASM(r, configOnly, "decode_thumbnail", true)
}

func decodeWASM(r io.Reader, configOnly bool, exportName string, thumbnail bool) (image.Image, image.Config, error) {
initOnce()

var cfg image.Config
var data []byte

ctx := context.Background()
mod, err := rt.InstantiateModule(ctx, cm, mc)
mod, err := rt.InstantiateModule(ctx, cm, mc.WithName(""))
if err != nil {
return nil, cfg, err
}
Expand All @@ -37,7 +45,7 @@ func decode(r io.Reader, configOnly bool) (image.Image, image.Config, error) {

_alloc := mod.ExportedFunction("malloc")
_free := mod.ExportedFunction("free")
_decode := mod.ExportedFunction("decode")
_decode := mod.ExportedFunction(exportName)

if configOnly {
data, err = io.ReadAll(io.LimitReader(r, heifMaxHeaderSize))
Expand Down Expand Up @@ -82,6 +90,9 @@ func decode(r io.Reader, configOnly bool) (image.Image, image.Config, error) {
return nil, cfg, fmt.Errorf("decode: %w", err)
}

if thumbnail && res[0] == 2 {
return nil, cfg, ErrNoThumbnail
}
if res[0] == 0 {
return nil, cfg, ErrDecode
}
Expand Down Expand Up @@ -183,6 +194,9 @@ func decode(r io.Reader, configOnly bool) (image.Image, image.Config, error) {
return nil, cfg, fmt.Errorf("decode: %w", err)
}

if thumbnail && res[0] == 2 {
return nil, cfg, ErrNoThumbnail
}
if res[0] == 0 {
return nil, cfg, ErrDecode
}
Expand Down
Loading