diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 3f49ca5..d97aebe 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -15,9 +15,8 @@ jobs: os: - ubuntu-latest - macos-15 - # 1.25 is the floor imposed by golang.org/x/image; keep in sync with go.mod. + # 1.26 is the floor imposed by github.com/gen2brain/h265; keep in sync with go.mod. go-version: - - '1.25' - '1.26' steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c060792..dfd0918 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: '1.26.0' + go-version: '1.26.4' - name: Test run: make test diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f24f18..476371e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased +- Add HEIC/HEIF decoding and encoding via `imgio.HEICEncoder` and `.heic`/`.heif` CLI output, backed by the pure Go `github.com/gen2brain/h265` codec +- Bump minimum supported Go version to 1.26 + ## 0.17.1 - Fix transparent pixels when resizing with the Box filter by @youdie006 in https://github.com/anthonynsimon/bild/pull/115 - Bump golang.org/x/image from 0.44.0 to 0.45.0 to address CVE-2026-46603 in the VP8L decoder, reported in https://github.com/anthonynsimon/bild/pull/116 diff --git a/README.md b/README.md index 20a4aa8..3bb9f29 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ bild imgio encode input.png output.webp ## Install package -bild requires Go version 1.25 or greater. +bild requires Go version 1.26 or greater. ```bash go get github.com/anthonynsimon/bild/... @@ -120,14 +120,19 @@ func main() { ## Supported formats -`imgio.Open` decodes PNG, JPEG, BMP and WebP images. The following encoders are available: +`imgio.Open` decodes PNG, JPEG, BMP, WebP and HEIC/HEIF images. The following encoders are available: - `imgio.PNGEncoder()` - `imgio.JPEGEncoder(quality)` - `imgio.BMPEncoder()` - `imgio.WEBPEncoder(options)` — pass `nil` for defaults +- `imgio.HEICEncoder(options)` — pass `nil` for defaults -The CLI selects the encoder from the output file extension (`.png`, `.jpg`/`.jpeg`, `.bmp`, `.webp`). +The CLI selects the encoder from the output file extension (`.png`, `.jpg`/`.jpeg`, `.bmp`, `.webp`, `.heic`/`.heif`). + +HEIC support is pure Go and adds no transitive dependencies, but note that HEVC is +covered by patents; distributing software that decodes or encodes it may require a +licence from the patent holders. # Output examples ## Adjustment diff --git a/cmd/helpers.go b/cmd/helpers.go index 9c856f1..56ceb4e 100644 --- a/cmd/helpers.go +++ b/cmd/helpers.go @@ -16,6 +16,7 @@ var jpgExtensions = []string{".jpg", ".jpeg"} var pngExtensions = []string{".png"} var bmpExtensions = []string{".bmp"} var webpExtensions = []string{".webp"} +var heicExtensions = []string{".heic", ".heif"} var ( // ErrWrongSize is thrown when the provided size string does not match the expected form. @@ -58,6 +59,12 @@ func resolveEncoder(outputfile string, defaultEncoding imgio.Encoder) imgio.Enco } } + for _, ext := range heicExtensions { + if strings.HasSuffix(lower, ext) { + return imgio.HEICEncoder(nil) + } + } + return defaultEncoding } diff --git a/go.mod b/go.mod index 2f517b7..10b5d18 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,10 @@ module github.com/anthonynsimon/bild -go 1.25.0 +go 1.26.4 require ( github.com/HugoSmits86/nativewebp v1.3.0 + github.com/gen2brain/h265 v0.2.2 github.com/spf13/cobra v1.10.2 golang.org/x/image v0.45.0 ) diff --git a/go.sum b/go.sum index ac487cd..f134038 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/HugoSmits86/nativewebp v1.3.0 h1:n1egtEzSV4KwFtealr7dzdYq1wI/uj/bOQ/QcTcIyVE= github.com/HugoSmits86/nativewebp v1.3.0/go.mod h1:YNQuWenlVmSUUASVNhTDwf4d7FwYQGbGhklC8p72Vr8= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/gen2brain/h265 v0.2.2 h1:rnpfo8I4PFhohbij8ySYZlJFn07TTwV+/uWBrKsOEas= +github.com/gen2brain/h265 v0.2.2/go.mod h1:kefpLxLhGpNCUG/q/aabJcD6GqB9Nhet3fNi9xX9Wzw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/imgio/io.go b/imgio/io.go index 8e92a2e..9b5bb75 100644 --- a/imgio/io.go +++ b/imgio/io.go @@ -9,6 +9,7 @@ import ( "os" "github.com/HugoSmits86/nativewebp" + "github.com/gen2brain/h265/heic" "golang.org/x/image/bmp" ) @@ -65,6 +66,17 @@ func WEBPEncoder(o *nativewebp.Options) Encoder { } } +// HEICEncoder returns an encoder to HEIC. Pass nil to encode with the +// default options, which are lossy 4:2:0 at 8 bits. +func HEICEncoder(o *heic.EncodeOptions) Encoder { + return func(w io.Writer, img image.Image) error { + if o == nil { + return heic.Encode(w, img) + } + return heic.Encode(w, img, *o) + } +} + // Save creates a file and writes to it an image using the provided encoder. // // Usage example: diff --git a/imgio/io_test.go b/imgio/io_test.go index 97e9941..28948f4 100644 --- a/imgio/io_test.go +++ b/imgio/io_test.go @@ -5,6 +5,9 @@ import ( "image" "strings" "testing" + + "github.com/anthonynsimon/bild/clone" + "github.com/anthonynsimon/bild/util" ) func TestEncode(t *testing.T) { @@ -52,6 +55,19 @@ func TestEncode(t *testing.T) { }, }, }, + { + format: "heic", + encoder: HEICEncoder(nil), + value: &image.RGBA{ + Rect: image.Rect(0, 0, 3, 3), + Stride: 3 * 4, + Pix: []uint8{ + 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0xFF, + 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0x80, 0x00, 0x00, 0xFF, + 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0xFF, + }, + }, + }, { format: "webp", encoder: WEBPEncoder(nil), @@ -79,3 +95,29 @@ func TestEncode(t *testing.T) { } } } + +func TestOpenHEIC(t *testing.T) { + // A 4x4 image of four solid 2x2 quadrants: red, green, blue, white, + // written losslessly at 4:4:4. The coded samples are exact, but the + // RGB to YCbCr conversion either way rounds, hence the tolerance. + expected := &image.RGBA{ + Rect: image.Rect(0, 0, 4, 4), + Stride: 4 * 4, + Pix: []uint8{ + 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, + 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, + 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + }, + } + + img, err := Open("testdata/quadrants.heic") + if err != nil { + t.Fatal(err) + } + + actual := clone.AsRGBA(img) + if !util.RGBAImageApproxEqual(actual, expected, 1) { + t.Errorf("%s: expected: %#v, actual: %#v", "Open HEIC", expected, actual) + } +} diff --git a/imgio/testdata/quadrants.heic b/imgio/testdata/quadrants.heic new file mode 100644 index 0000000..360c5e9 Binary files /dev/null and b/imgio/testdata/quadrants.heic differ