diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..167878a --- /dev/null +++ b/CLAUDE.md @@ -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=` 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=` 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. + diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..66fcce1 --- /dev/null +++ b/PLAN.md @@ -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 ` 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 +``` + +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. + diff --git a/decode.go b/decode.go index edd3262..2e5fbbf 100644 --- a/decode.go +++ b/decode.go @@ -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 } @@ -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)) @@ -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 } @@ -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 } diff --git a/decode_dynamic.go b/decode_dynamic.go index b08931f..00c9cf6 100644 --- a/decode_dynamic.go +++ b/decode_dynamic.go @@ -220,6 +220,10 @@ func init() { purego.RegisterLibFunc(&_heifImageHandleGetPreferredDecodingColorspace, libheif, "heif_image_handle_get_preferred_decoding_colorspace") } + 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") + purego.RegisterLibFunc(&_heifCheckFiletype, libheif, "heif_check_filetype") purego.RegisterLibFunc(&_heifContextAlloc, libheif, "heif_context_alloc") purego.RegisterLibFunc(&_heifContextFree, libheif, "heif_context_free") @@ -248,6 +252,9 @@ var ( var ( _heifGetVersionNumberMajor func() uint32 _heifGetVersionNumberMinor func() uint32 + _heifImageHandleGetNumberOfThumbnails func(*heifImageHandle) int + _heifImageHandleGetListOfThumbnailIDs func(*heifImageHandle, *uint32, int) int + _heifImageHandleGetThumbnail func(*heifImageHandle, uint32, **heifImageHandle) uintptr _heifCheckFiletype func(*uint8, uint64) int _heifContextAlloc func() *heifContext _heifContextFree func(*heifContext) @@ -338,6 +345,207 @@ func heifImageGetPlaneReadonly(img *heifImage, channel int, stride *int) *uint8 return _heifImageGetPlaneReadonly(img, channel, stride) } +func heifImageHandleGetNumberOfThumbnails(h *heifImageHandle) int { + return _heifImageHandleGetNumberOfThumbnails(h) +} + +func heifImageHandleGetListOfThumbnailIDs(h *heifImageHandle, ids []uint32) int { + return _heifImageHandleGetListOfThumbnailIDs(h, &ids[0], len(ids)) +} + +func heifImageHandleGetThumbnail(h *heifImageHandle, id uint32, out **heifImageHandle) heifError { + ret := _heifImageHandleGetThumbnail(h, id, out) + return *(*heifError)(unsafe.Pointer(&ret)) +} + +func decodeThumbnailDynamic(r io.Reader, configOnly bool) (image.Image, image.Config, error) { + var err error + var cfg image.Config + var data []byte + + if configOnly { + data, err = io.ReadAll(io.LimitReader(r, heifMaxHeaderSize)) + if err != nil { + return nil, cfg, fmt.Errorf("read: %w", err) + } + } else { + data, err = io.ReadAll(r) + if err != nil { + return nil, cfg, fmt.Errorf("read: %w", err) + } + } + + check := heifCheckFiletype(data) + if check != heifFiletypeYesSupported { + return nil, cfg, ErrDecode + } + + ctx := heifContextAlloc() + defer heifContextFree(ctx) + + var e heifError + + e = heifContextReadFromMemoryWithoutCopy(ctx, data) + if e.Code != 0 { + return nil, cfg, ErrDecode + } + + handle := new(heifImageHandle) + + e = heifContextGetPrimaryImageHandle(ctx, &handle) + if e.Code != 0 { + return nil, cfg, ErrDecode + } + defer heifImageHandleRelease(handle) + + n := heifImageHandleGetNumberOfThumbnails(handle) + if n == 0 { + return nil, cfg, ErrNoThumbnail + } + + ids := make([]uint32, 1) + heifImageHandleGetListOfThumbnailIDs(handle, ids) + + thumb := new(heifImageHandle) + e = heifImageHandleGetThumbnail(handle, ids[0], &thumb) + if e.Code != 0 { + return nil, cfg, ErrDecode + } + defer heifImageHandleRelease(thumb) + + cfg.Width = heifImageHandleGetWidth(thumb) + cfg.Height = heifImageHandleGetHeight(thumb) + + isPremultiplied := heifImageHandleIsPremultipliedAlpha(thumb) + + var colorspace, chroma int + if versionMajor == 1 && versionMinor >= 17 { + e = heifImageHandleGetPreferredDecodingColorspace(thumb, &colorspace, &chroma) + if e.Code != 0 { + return nil, cfg, ErrDecode + } + + if colorspace == heifColorspaceUndefined || chroma == heifChromaUndefined { + colorspace = heifColorspaceYCbCr + chroma = heifChroma420 + cfg.ColorModel = color.YCbCrModel + } + if colorspace == heifColorspaceRGB { + chroma = heifChromaInterleavedRGBA + if isPremultiplied { + cfg.ColorModel = color.RGBAModel + } else { + cfg.ColorModel = color.NRGBAModel + } + } + } else { + colorspace = heifColorspaceYCbCr + chroma = heifChroma420 + cfg.ColorModel = color.YCbCrModel + } + + if configOnly { + return nil, cfg, nil + } + + options := heifDecodingOptionsAlloc() + options.ConvertHdrTo8bit = 1 + defer heifDecodingOptionsFree(options) + + heifImg := new(heifImage) + + e = heifDecodeImage(thumb, &heifImg, colorspace, chroma, options) + if e.Code != 0 { + return nil, cfg, ErrDecode + } + + var img image.Image + rect := image.Rect(0, 0, cfg.Width, cfg.Height) + + switch colorspace { + case heifColorspaceYCbCr: + var subsampleRatio image.YCbCrSubsampleRatio + switch chroma { + case heifChroma420: + subsampleRatio = image.YCbCrSubsampleRatio420 + case heifChroma422: + subsampleRatio = image.YCbCrSubsampleRatio422 + case heifChroma444: + subsampleRatio = image.YCbCrSubsampleRatio444 + } + + var yStride, uStride int + y := heifImageGetPlaneReadonly(heifImg, heifChannelY, &yStride) + cb := heifImageGetPlaneReadonly(heifImg, heifChannelCb, &uStride) + cr := heifImageGetPlaneReadonly(heifImg, heifChannelCr, &uStride) + + _, _, _, ch := yCbCrSize(rect, subsampleRatio) + i0 := yStride * cfg.Height + i1 := yStride*cfg.Height + 1*uStride*ch + i2 := yStride*cfg.Height + 2*uStride*ch + b := make([]byte, i2) + + i := &image.YCbCr{ + Y: b[:i0:i0], + Cb: b[i0:i1:i1], + Cr: b[i1:i2:i2], + SubsampleRatio: subsampleRatio, + YStride: yStride, + CStride: uStride, + Rect: rect, + } + + copy(i.Y, unsafe.Slice(y, yStride*cfg.Height)) + copy(i.Cb, unsafe.Slice(cb, uStride*ch)) + copy(i.Cr, unsafe.Slice(cr, uStride*ch)) + + img = i + case heifColorspaceMonochrome: + var stride int + grayData := heifImageGetPlaneReadonly(heifImg, heifChannelY, &stride) + size := cfg.Height * stride + + i := &image.Gray{ + Pix: make([]uint8, size), + Stride: stride, + Rect: rect, + } + + copy(i.Pix, unsafe.Slice(grayData, size)) + img = i + case heifColorspaceRGB: + var stride int + rgbaData := heifImageGetPlaneReadonly(heifImg, heifChannelInterleaved, &stride) + size := cfg.Height * stride + + if isPremultiplied { + i := &image.RGBA{ + Pix: make([]uint8, size), + Stride: stride, + Rect: rect, + } + + copy(i.Pix, unsafe.Slice(rgbaData, size)) + img = i + } else { + i := &image.NRGBA{ + Pix: make([]uint8, size), + Stride: stride, + Rect: rect, + } + + copy(i.Pix, unsafe.Slice(rgbaData, size)) + img = i + } + default: + return nil, cfg, fmt.Errorf("unsupported colorspace %d", colorspace) + } + + runtime.KeepAlive(data) + + return img, cfg, nil +} + type heifContext struct{} type heifImageHandle struct{} type heifImage struct{} diff --git a/decode_test.go b/decode_test.go index f6dbe77..26dfd67 100644 --- a/decode_test.go +++ b/decode_test.go @@ -3,6 +3,7 @@ package heic import ( "bytes" _ "embed" + "errors" "image" "image/jpeg" "io" @@ -25,6 +26,9 @@ var testHeic12 []byte //go:embed testdata/gray.heic var testGray []byte +//go:embed testdata/IMG_2736.HEIC +var testThumb []byte + func TestDecode(t *testing.T) { img, _, err := decode(bytes.NewReader(testHeic), false) if err != nil { @@ -312,6 +316,83 @@ func testBothWays(t *testing.T, fn func(t *testing.T)) { }) } +func TestDecodeThumbnail(t *testing.T) { + img, _, err := decodeThumbnail(bytes.NewReader(testThumb), false) + if err != nil { + t.Fatal(err) + } + + b := img.Bounds() + if b.Dx() == 0 || b.Dy() == 0 { + t.Fatalf("thumbnail has zero dimension: %dx%d", b.Dx(), b.Dy()) + } + + w, err := writeCloser() + if err != nil { + t.Fatal(err) + } + defer w.Close() + + err = jpeg.Encode(w, img, nil) + if err != nil { + t.Error(err) + } +} + +func TestDecodeThumbnailConfig(t *testing.T) { + _, cfg, err := decodeThumbnail(bytes.NewReader(testThumb), true) + if err != nil { + t.Fatal(err) + } + + if cfg.Width == 0 || cfg.Height == 0 { + t.Fatalf("thumbnail config has zero dimension: %dx%d", cfg.Width, cfg.Height) + } +} + +func TestDecodeThumbnailDynamic(t *testing.T) { + requireDynamic(t) + if versionMajor < 1 || (versionMajor == 1 && versionMinor < 18) { + t.Skipf("skipping: libheif %d.%d cannot read iPhone HDR files; need >= 1.18", versionMajor, versionMinor) + } + + img, _, err := decodeThumbnailDynamic(bytes.NewReader(testThumb), false) + if err != nil { + t.Fatal(err) + } + + b := img.Bounds() + if b.Dx() == 0 || b.Dy() == 0 { + t.Fatalf("thumbnail has zero dimension: %dx%d", b.Dx(), b.Dy()) + } + + w, err := writeCloser() + if err != nil { + t.Fatal(err) + } + defer w.Close() + + err = jpeg.Encode(w, img, nil) + if err != nil { + t.Error(err) + } +} + +func TestDecodeThumbnailMissing(t *testing.T) { + // testHeic has no embedded thumbnail — both paths must return ErrNoThumbnail. + _, _, err := decodeThumbnail(bytes.NewReader(testHeic), false) + if !errors.Is(err, ErrNoThumbnail) { + t.Errorf("wasm: got %v, want ErrNoThumbnail", err) + } + + if Dynamic() == nil { + _, _, err = decodeThumbnailDynamic(bytes.NewReader(testHeic), false) + if !errors.Is(err, ErrNoThumbnail) { + t.Errorf("dynamic: got %v, want ErrNoThumbnail", err) + } + } +} + func BenchmarkDecode(b *testing.B) { for i := 0; i < b.N; i++ { _, _, err := decode(bytes.NewReader(testHeic8), false) diff --git a/go.mod b/go.mod index 1834c5a..1a73292 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/gen2brain/heic +module github.com/Rosca75/heic go 1.25 diff --git a/heic.go b/heic.go index 1eb3287..7ecea7d 100644 --- a/heic.go +++ b/heic.go @@ -9,9 +9,10 @@ import ( // Errors . var ( - ErrMemRead = errors.New("heic: mem read failed") - ErrMemWrite = errors.New("heic: mem write failed") - ErrDecode = errors.New("heic: decode failed") + ErrMemRead = errors.New("heic: mem read failed") + ErrMemWrite = errors.New("heic: mem write failed") + ErrDecode = errors.New("heic: decode failed") + ErrNoThumbnail = errors.New("heic: no thumbnail") ) // Decode reads a HEIC image from r and returns it as an image.Image. @@ -54,6 +55,49 @@ func DecodeConfig(r io.Reader) (image.Config, error) { return cfg, nil } +// DecodeThumbnail reads a HEIC image from r and returns its embedded thumbnail +// as an image.Image. If no thumbnail is present, ErrNoThumbnail is returned. +func DecodeThumbnail(r io.Reader) (image.Image, error) { + var err error + var img image.Image + + if dynamic && !ForceWasmMode { + img, _, err = decodeThumbnailDynamic(r, false) + if err != nil { + return nil, err + } + } else { + img, _, err = decodeThumbnail(r, false) + if err != nil { + return nil, err + } + } + + return img, nil +} + +// DecodeThumbnailConfig returns the color model and dimensions of the embedded +// thumbnail without decoding the full image. If no thumbnail is present, +// ErrNoThumbnail is returned. +func DecodeThumbnailConfig(r io.Reader) (image.Config, error) { + var err error + var cfg image.Config + + if dynamic && !ForceWasmMode { + _, cfg, err = decodeThumbnailDynamic(r, true) + if err != nil { + return image.Config{}, err + } + } else { + _, cfg, err = decodeThumbnail(r, true) + if err != nil { + return image.Config{}, err + } + } + + return cfg, nil +} + // ForceWasmMode, if true, forces using the WASM-based decoder even if a // dynamic/shared library is available. // diff --git a/lib/Makefile b/lib/Makefile index 862fb91..4132162 100644 --- a/lib/Makefile +++ b/lib/Makefile @@ -35,6 +35,9 @@ $(LIBDE265_SRC): sed -i '/^target_link_libraries/d' $(LIBDE265_SRC)/libde265/CMakeLists.txt sed -i '/static std::mutex/,+4d' $(LIBDE265_SRC)/libde265/de265.cc sed -i '/std::mutex/d' $(LIBDE265_SRC)/libde265/de265.cc + python3 -c "p='$(LIBDE265_SRC)/libde265/CMakeLists.txt'; open(p,'w').write(open(p).read().replace('add_subdirectory (encoder)','if(ENABLE_ENCODER)\nadd_subdirectory (encoder)\nendif()'))" + python3 -c "p='$(LIBDE265_SRC)/libde265/threads.h'; t=open(p).read(); open(p,'w').write(t.replace('#include \n\ntypedef pthread_t de265_thread;\ntypedef pthread_mutex_t de265_mutex;\ntypedef pthread_cond_t de265_cond;','#ifdef __wasi__\ntypedef int de265_thread;\ntypedef int de265_mutex;\ntypedef int de265_cond;\n#else\n#include \ntypedef pthread_t de265_thread;\ntypedef pthread_mutex_t de265_mutex;\ntypedef pthread_cond_t de265_cond;\n#endif'))" + python3 -c "p='$(LIBDE265_SRC)/libde265/threads.cc'; t=open(p).read(); old='int de265_thread_create(de265_thread* t, void *(*start_routine) (void *), void *arg) { return pthread_create(t,NULL,start_routine,arg); }'; new='#if defined(__wasi__)\nint de265_thread_create(de265_thread* t, void *(*start_routine) (void *), void *arg) { return 0; }\nvoid de265_thread_join(de265_thread t) {}\nvoid de265_thread_destroy(de265_thread* t) {}\nvoid de265_mutex_init(de265_mutex* m) {}\nvoid de265_mutex_destroy(de265_mutex* m) {}\nvoid de265_mutex_lock(de265_mutex* m) {}\nvoid de265_mutex_unlock(de265_mutex* m) {}\nvoid de265_cond_init(de265_cond* c) {}\nvoid de265_cond_destroy(de265_cond* c) {}\nvoid de265_cond_broadcast(de265_cond* c,de265_mutex* m) {}\nvoid de265_cond_wait(de265_cond* c,de265_mutex* m) {}\nvoid de265_cond_signal(de265_cond* c) {}\n#else\nint de265_thread_create(de265_thread* t, void *(*start_routine) (void *), void *arg) { return pthread_create(t,NULL,start_routine,arg); }'; open(p,'w').write(t.replace(old,new))" mkdir -p $(LIBDE265_BUILD) test -d $@ @@ -94,6 +97,7 @@ $(BIN): $(LIBDE265_BUILD)/libde265/libde265.a $(LIBHEIF_BUILD)/libheif/libheif.a -Wl,--export=malloc \ -Wl,--export=free \ -Wl,--export=decode \ + -Wl,--export=decode_thumbnail \ -mexec-model=reactor \ -mnontrapping-fptoint \ -I${LIBHEIF_SRC}/libheif/api \ diff --git a/lib/heif.c b/lib/heif.c index 5b4a849..41b8fef 100644 --- a/lib/heif.c +++ b/lib/heif.c @@ -120,6 +120,145 @@ int decode(uint8_t *heic_in, int heic_in_size, int config_only, uint32_t *width, return 1; } +/* decode_thumbnail: returns 0=failure, 1=success, 2=no thumbnail */ +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); + +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) { + + enum heif_filetype_result filetype_check = heif_check_filetype(heic_in, heic_in_size); + if(filetype_check != heif_filetype_yes_supported) { + return 0; + } + + struct heif_error err; + + struct heif_context *context = heif_context_alloc(); + err = heif_context_read_from_memory_without_copy(context, heic_in, heic_in_size, NULL); + if(err.code != heif_error_Ok) { + heif_context_free(context); + return 0; + } + + heif_context_set_max_decoding_threads(context, 0); + + struct heif_image_handle *handle; + err = heif_context_get_primary_image_handle(context, &handle); + if(err.code != heif_error_Ok) { + heif_context_free(context); + return 0; + } + + int n_thumbs = heif_image_handle_get_number_of_thumbnails(handle); + if(n_thumbs == 0) { + heif_image_handle_release(handle); + heif_context_free(context); + return 2; + } + + heif_item_id thumb_id; + heif_image_handle_get_list_of_thumbnail_IDs(handle, &thumb_id, 1); + + struct heif_image_handle *thumb_handle; + err = heif_image_handle_get_thumbnail(handle, thumb_id, &thumb_handle); + if(err.code != heif_error_Ok) { + heif_image_handle_release(handle); + heif_context_free(context); + return 0; + } + + *width = (uint32_t)heif_image_handle_get_width(thumb_handle); + *height = (uint32_t)heif_image_handle_get_height(thumb_handle); + + *is_premultiplied = (uint32_t)heif_image_handle_is_premultiplied_alpha(thumb_handle); + + err = heif_image_handle_get_preferred_decoding_colorspace(thumb_handle, colorspace, chroma); + if(err.code != heif_error_Ok) { + heif_image_handle_release(thumb_handle); + heif_image_handle_release(handle); + heif_context_free(context); + return 0; + } + + if(*colorspace == heif_colorspace_undefined || *chroma == heif_chroma_undefined) { + *colorspace = heif_colorspace_YCbCr; + *chroma = heif_chroma_420; + } + if(*colorspace == heif_colorspace_RGB) { + *chroma = heif_chroma_interleaved_RGBA; + } + + if(config_only) { + heif_image_handle_release(thumb_handle); + heif_image_handle_release(handle); + heif_context_free(context); + return 1; + } + + struct heif_decoding_options* options = heif_decoding_options_alloc(); + options->convert_hdr_to_8bit = 1; + + struct heif_image *img; + + err = heif_decode_image(thumb_handle, &img, *colorspace, *chroma, options); + if(err.code != heif_error_Ok) { + heif_image_handle_release(thumb_handle); + heif_image_handle_release(handle); + heif_context_free(context); + return 0; + } + + if(*colorspace == heif_colorspace_YCbCr) { + int h = *height; + int ch = 0; + + switch(*chroma) { + case heif_chroma_420: + ch = (h+1)/2; + break; + case heif_chroma_422: + ch = h; + break; + case heif_chroma_444: + ch = h; + break; + default: + break; + } + + int y_stride; + int u_stride; + + const uint8_t *y = heif_image_get_plane_readonly(img, heif_channel_Y, &y_stride); + const uint8_t *cb = heif_image_get_plane_readonly(img, heif_channel_Cb, &u_stride); + const uint8_t *cr = heif_image_get_plane_readonly(img, heif_channel_Cr, NULL); + + int i0 = y_stride * h; + int i1 = y_stride * h + u_stride*ch; + + memcpy(out, y, y_stride * h); + memcpy(out + i0, cb, u_stride * ch); + memcpy(out + i1, cr, u_stride * ch); + } else if(*colorspace == heif_colorspace_monochrome){ + int stride; + const uint8_t *image = heif_image_get_plane_readonly(img, heif_channel_Y, &stride); + + memcpy(out, image, *height * stride); + } else { + int stride; + const uint8_t *image = heif_image_get_plane_readonly(img, heif_channel_interleaved, &stride); + + memcpy(out, image, *height * stride); + } + + heif_decoding_options_free(options); + heif_image_handle_release(thumb_handle); + heif_image_handle_release(handle); + heif_context_free(context); + return 1; +} + int __cxa_allocate_exception(int a) { return 0; } diff --git a/lib/heif.wasm.gz b/lib/heif.wasm.gz old mode 100644 new mode 100755 index 36bee07..f6b9d03 Binary files a/lib/heif.wasm.gz and b/lib/heif.wasm.gz differ diff --git a/purego_other.go b/purego_other.go index 1460246..57832a8 100644 --- a/purego_other.go +++ b/purego_other.go @@ -17,6 +17,10 @@ func decodeDynamic(r io.Reader, configOnly bool) (image.Image, image.Config, err return nil, image.Config{}, dynamicErr } +func decodeThumbnailDynamic(r io.Reader, configOnly bool) (image.Image, image.Config, error) { + return nil, image.Config{}, dynamicErr +} + func loadLibrary() (uintptr, error) { return 0, dynamicErr } diff --git a/testdata/IMG_2736.HEIC b/testdata/IMG_2736.HEIC new file mode 100644 index 0000000..34731ac Binary files /dev/null and b/testdata/IMG_2736.HEIC differ