[image] x/image: add basic support for animated webp files

99 views
Skip to first unread message

Gopher Robot (Gerrit)

unread,
Jul 25, 2023, 10:42:49 PM7/25/23
to Patrick Smith, goph...@pubsubhelper.golang.org, golang-co...@googlegroups.com

Congratulations on opening your first change. Thank you for your contribution!

Next steps:
A maintainer will review your change and provide feedback. See
https://go.dev/doc/contribute#review for more info and tips to get your
patch through code review.

Most changes in the Go project go through a few rounds of revision. This can be
surprising to people new to the project. The careful, iterative review process
is our way of helping mentor contributors and ensuring that their contributions
have a lasting impact.

View Change

    To view, visit change 513295. To unsubscribe, or for help writing mail filters, visit settings.

    Gerrit-MessageType: comment
    Gerrit-Project: image
    Gerrit-Branch: master
    Gerrit-Change-Id: I2bcc59bfcde78b5f2163252dbde5563912c7bfd0
    Gerrit-Change-Number: 513295
    Gerrit-PatchSet: 1
    Gerrit-Owner: Patrick Smith <pat...@one8dev.com>
    Gerrit-CC: Gopher Robot <go...@golang.org>
    Gerrit-Comment-Date: Wed, 26 Jul 2023 02:42:46 +0000
    Gerrit-HasComments: No
    Gerrit-Has-Labels: No

    Patrick Smith (Gerrit)

    unread,
    Jul 26, 2023, 12:56:11 PM7/26/23
    to goph...@pubsubhelper.golang.org, golang-co...@googlegroups.com

    Patrick Smith has uploaded this change for review.

    View Change

    x/image: add basic support for animated webp files

    Change-Id: I2bcc59bfcde78b5f2163252dbde5563912c7bfd0
    ---
    A riff/subchunkreader.go
    A riff/subchunkreader_test.go
    A testdata/animated-webp-lossless.webp
    A testdata/animated-webp-lossy.webp
    A webp/animated.go
    A webp/animated_test.go
    M webp/decode.go
    A webp/webp.go
    8 files changed, 591 insertions(+), 16 deletions(-)

    diff --git a/riff/subchunkreader.go b/riff/subchunkreader.go
    new file mode 100644
    index 0000000..6971cd6
    --- /dev/null
    +++ b/riff/subchunkreader.go
    @@ -0,0 +1,72 @@
    +package riff
    +
    +import (
    + "bytes"
    + "errors"
    + "io"
    +)
    +
    +var (
    + errInvalidHeader = errors.New("could not read an 8 byte header, sub-chunk is not valid")
    + errInvalidFormat = errors.New("could not read sufficient number of bytes from sub-chunk data block")
    +)
    +
    +// SubChunkReader helps in reading riff data from an existing chunk which are comprised of sub-chunks.
    +// A good example would be ANMF chunks of animated webp files. These chunks can contain headers, ALPH chunks
    +// and VP8 or VP8L chunks within the main riff data chunk.
    +type SubChunkReader struct {
    + r io.Reader
    +}
    +
    +// Next will return the FourCC, data, and data length of a subchunk.
    +// The io.Reader returned for data will not be the same as the provided reader
    +// and is safe to discord without fully reading the contents.
    +// Will return an error if the format is invalid or an underlying read operation fails.
    +func (c SubChunkReader) Next() (FourCC, io.Reader, uint32, error) {
    + header := make([]byte, 8)
    + n, err := io.ReadFull(c.r, header)
    + if err != nil {
    + if err == io.ErrUnexpectedEOF {
    + return FourCC{}, nil, 0, errInvalidHeader
    + }
    + return FourCC{}, nil, 0, err
    + }
    + if n != 8 {
    + return FourCC{}, nil, 0, errInvalidHeader
    + }
    +
    + fourCC := FourCC{header[0], header[1], header[2], header[3]}
    + chunkLen := u32(header[4:8])
    + buf := make([]byte, chunkLen)
    + n, err = io.ReadFull(c.r, buf)
    + if err != nil {
    + if err == io.ErrUnexpectedEOF {
    + return FourCC{}, nil, 0, errInvalidFormat
    + }
    + return FourCC{}, nil, 0, err
    + }
    + if n != int(chunkLen) {
    + return FourCC{}, nil, 0, errInvalidFormat
    + }
    +
    + // if chunkLen was odd, we need to maintain a 2-byte boundary per RIFF spec.
    + // in this case read off a single byte of padding to re-align with the next
    + // fourCC header
    + if chunkLen%2 == 1 {
    + n, err := c.r.Read([]byte{0})
    + if err != nil {
    + return FourCC{}, nil, 0, err
    + }
    + if n != 1 {
    + return FourCC{}, nil, 0, errInvalidFormat
    + }
    + }
    +
    + return fourCC, bytes.NewReader(buf), chunkLen, nil
    +}
    +
    +func NewSubChunkReader(r io.Reader) *SubChunkReader {
    + return &SubChunkReader{
    + r: r,
    + }
    +}
    diff --git a/riff/subchunkreader_test.go b/riff/subchunkreader_test.go
    new file mode 100644
    index 0000000..d819ffb
    --- /dev/null
    +++ b/riff/subchunkreader_test.go
    @@ -0,0 +1,75 @@
    +package riff
    +
    +import (
    + "bytes"
    + "io"
    + "testing"
    +)
    +
    +func TestSubChunkReader(t *testing.T) {
    + for _, numSubChunks := range []int{1, 5, 10, 20} {
    + chunk := make([]byte, 0, 2048)
    + wantLength := uint32(128)
    + for i := 0; i < numSubChunks; i++ {
    + chunk = append(chunk, genSubChunk(wantLength)...)
    + }
    +
    + r := NewSubChunkReader(bytes.NewReader(chunk))
    + chunksRead := 0
    + for {
    + fourCC, data, dataLen, err := r.Next()
    + if err != nil {
    + if err == io.EOF {
    + break
    + }
    + t.Errorf("unexpected error: %s", err.Error())
    + t.FailNow()
    + }
    + if dataLen != wantLength {
    + t.Errorf("wanted subchunk length: %d, got %d", wantLength, dataLen)
    + }
    + if fourCC != [4]byte{'A', 'B', 'C', 'D'} {
    + t.Errorf("fourCC was not ABCD")
    + }
    +
    + dataBytes, err := io.ReadAll(data)
    + if err != nil {
    + t.Errorf("failed to read data from subchunk reader: %s", err.Error())
    + t.FailNow()
    + }
    + if len(dataBytes) != int(wantLength) {
    + t.Errorf("wanted datalen of %d, but got %d", wantLength, len(dataBytes))
    + }
    +
    + chunksRead++
    + }
    + if chunksRead != numSubChunks {
    + t.Errorf("expected %d subchunks, but got %d", numSubChunks, chunksRead)
    + }
    + }
    +}
    +
    +func TestSubChunkReader_ChunkDataTooShort(t *testing.T) {
    + // Generate a chunk, but strip some data off the end
    + chunk := genSubChunk(256)[:256]
    + r := NewSubChunkReader(bytes.NewReader(chunk))
    + _, _, _, err := r.Next()
    + if err != errInvalidFormat {
    + t.Errorf("expected invalid format error, but got %v", err)
    + }
    +}
    +func TestSubChunkReader_HeaderTooShort(t *testing.T) {
    + // Generate a chunk, but strip some data off the end
    + chunk := make([]byte, 3)
    + r := NewSubChunkReader(bytes.NewReader(chunk))
    + _, _, _, err := r.Next()
    + if err != errInvalidHeader {
    + t.Errorf("expected invalid format error, but got %v", err)
    + }
    +}
    +
    +func genSubChunk(length uint32) []byte {
    + header := append([]byte("ABCD"), encodeU32(length)...)
    + data := make([]byte, length)
    + return append(header, data...)
    +}
    diff --git a/testdata/animated-webp-lossless.webp b/testdata/animated-webp-lossless.webp
    new file mode 100644
    index 0000000..888378a
    --- /dev/null
    +++ b/testdata/animated-webp-lossless.webp
    Binary files differ
    diff --git a/testdata/animated-webp-lossy.webp b/testdata/animated-webp-lossy.webp
    new file mode 100644
    index 0000000..b0444ca
    --- /dev/null
    +++ b/testdata/animated-webp-lossy.webp
    Binary files differ
    diff --git a/webp/animated.go b/webp/animated.go
    new file mode 100644
    index 0000000..f8a9899
    --- /dev/null
    +++ b/webp/animated.go
    @@ -0,0 +1,208 @@
    +package webp
    +
    +import (
    + "bytes"
    + "errors"
    + "image"
    + "io"
    +
    + "golang.org/x/image/riff"
    + "golang.org/x/image/vp8"
    + "golang.org/x/image/vp8l"
    +)
    +
    +var (
    + errNotExtended = errors.New("there was no vp8x header in this webp file, it cannot be animated")
    + errNotAnimated = errors.New("the vp8x header did not have the animation bit set")
    +)
    +
    +func decodeAnimated(r io.Reader) (*AnimatedWEBP, error) {
    + riffReader, err := webpRiffReader(r)
    + if err != nil {
    + return nil, err
    + }
    +
    + vp8xHeader, err := validateVP8XHeader(riffReader)
    + if err != nil {
    + return nil, err
    + }
    +
    + animHeader, err := validateANIMHeader(riffReader)
    + if err != nil {
    + return nil, err
    + }
    + awp := AnimatedWEBP{
    + Frames: make([]Frame, 0, 128),
    + Header: animHeader,
    + Config: image.Config{
    + ColorModel: nil, // TODO(patricsss) set the color model correctly
    + Width: int(vp8xHeader.CanvasWidth),
    + Height: int(vp8xHeader.CanvasHeight),
    + },
    + }
    +
    + for {
    + frame, err := parseFrame(riffReader, vp8xHeader)
    + if err != nil {
    + if err == io.EOF {
    + break
    + }
    + return nil, err
    + }
    +
    + awp.Frames = append(awp.Frames, *frame)
    + }
    +
    + return &awp, nil
    +}
    +
    +func validateVP8XHeader(r *riff.Reader) (VP8XHeader, error) {
    + fourCC, chunkLen, chunkData, err := r.Next()
    + if err != nil {
    + return VP8XHeader{}, err
    + }
    + if fourCC != fccVP8X {
    + return VP8XHeader{}, errNotExtended
    + }
    + if chunkLen != 10 {
    + return VP8XHeader{}, errInvalidFormat
    + }
    +
    + h := parseVP8XHeader(chunkData)
    + if !h.Animation {
    + return VP8XHeader{}, errNotAnimated
    + }
    +
    + return h, nil
    +}
    +
    +func validateANIMHeader(r *riff.Reader) (ANIMHeader, error) {
    + fourCC, chunkLen, chunkData, err := r.Next()
    + if err != nil {
    + return ANIMHeader{}, err
    + }
    + if fourCC != fccANIM {
    + return ANIMHeader{}, errInvalidFormat
    + }
    + if chunkLen != 6 {
    + return ANIMHeader{}, errInvalidFormat
    + }
    +
    + h := parseANIMHeader(chunkData)
    +
    + return h, nil
    +}
    +
    +func parseFrame(r *riff.Reader, h VP8XHeader) (*Frame, error) {
    + fourCC, chunkLen, chunkData, err := r.Next()
    + if err != nil {
    + return nil, err
    + }
    + if fourCC != fccANMF {
    + return nil, errInvalidFormat
    + }
    +
    + anmfHeader := parseANMFHeader(chunkData)
    +
    + // buffer chunk data based on chunkLen for safety
    + // TODO(patricsss): establish if this is necessary, perhaps chunkData has a bounds
    + // ANMF headers are 16 bytes
    + wrappedChunkData, err := rewrap(chunkData, int(chunkLen-16))
    + if err != nil {
    + return nil, err
    + }
    + subReader := riff.NewSubChunkReader(wrappedChunkData)
    +
    + var (
    + alpha []byte
    + stride int
    + i *image.YCbCr
    + )
    +
    + subFourCC, subChunkData, subChunkLen, err := subReader.Next()
    + if subFourCC == fccALPH {
    + alpha, stride, err = decodeAlpha(subChunkData, int(subChunkLen), anmfHeader)
    + if err != nil {
    + return nil, err
    + }
    + // read next chunk
    + subFourCC, subChunkData, subChunkLen, err = subReader.Next()
    + if err != nil {
    + return nil, err
    + }
    + }
    + if subFourCC != fccVP8 && subFourCC != fccVP8L {
    + return nil, errInvalidFormat
    + }
    +
    + var out image.Image
    + if subFourCC == fccVP8 {
    + i, err = decodeVp8Bitstream(subChunkData, int(subChunkLen))
    + if err != nil {
    + return nil, err
    + }
    + if alpha != nil && len(alpha) > 0 {
    + out = &image.NYCbCrA{
    + YCbCr: *i,
    + A: alpha,
    + AStride: stride,
    + }
    + }
    + } else if subFourCC == fccVP8L {
    + out, err = vp8l.Decode(subChunkData)
    + if err != nil {
    + return nil, err
    + }
    + }
    +
    + return &Frame{
    + Header: anmfHeader,
    + Frame: out,
    + }, nil
    +}
    +
    +func decodeVp8Bitstream(chunkData io.Reader, chunkLen int) (*image.YCbCr, error) {
    + dec := vp8.NewDecoder()
    + dec.Init(chunkData, chunkLen)
    +
    + _, err := dec.DecodeFrameHeader()
    + if err != nil {
    + return nil, err
    + }
    +
    + i, err := dec.DecodeFrame()
    + if err != nil {
    + return nil, err
    + }
    +
    + return i, nil
    +}
    +
    +func decodeAlpha(chunkData io.Reader, chunkLen int, h ANMFHeader) (alpha []byte, alphaStride int, err error) {
    + alphHeader := parseALPHHeader(chunkData)
    + // Length of the chunk minus 1 byte for the ALPH header
    + buf := make([]byte, chunkLen-1)
    + n, err := io.ReadFull(chunkData, buf)
    + if err != nil {
    + return nil, 0, err
    + }
    + if n != len(buf) {
    + return nil, 0, errInvalidFormat
    + }
    +
    + alpha, alphaStride, err = readAlpha(bytes.NewReader(buf), h.FrameWidth-1, h.FrameHeight-1, alphHeader.Compression)
    + unfilterAlpha(alpha, alphaStride, alphHeader.FilteringMethod)
    + return alpha, alphaStride, nil
    +}
    +
    +func rewrap(r io.Reader, length int) (io.Reader, error) {
    + data := make([]byte, length)
    + n, err := io.ReadFull(r, data)
    + if err != nil {
    + return nil, err
    + }
    + if n != length {
    + return nil, errInvalidFormat
    + }
    + return bytes.NewReader(data), nil
    +}
    diff --git a/webp/animated_test.go b/webp/animated_test.go
    new file mode 100644
    index 0000000..8ee5151
    --- /dev/null
    +++ b/webp/animated_test.go
    @@ -0,0 +1,52 @@
    +package webp
    +
    +import (
    + "bytes"
    + "os"
    + "testing"
    +)
    +
    +func TestDecodeAnimatedLossy(t *testing.T) {
    + type testCase struct {
    + width int
    + height int
    + numFrames int
    + file string
    + }
    + testCases := []testCase{
    + {
    + width: 250,
    + height: 260,
    + numFrames: 4,
    + file: "../testdata/animated-webp-lossless.webp",
    + },
    + {
    + width: 128,
    + height: 128,
    + numFrames: 28,
    + file: "../testdata/animated-webp-lossy.webp",
    + },
    + }
    + for _, tc := range testCases {
    + webpData, err := os.ReadFile(tc.file)
    + if err != nil {
    + t.Error(err.Error())
    + t.FailNow()
    + }
    +
    + anim, err := DecodeAnimated(bytes.NewReader(webpData))
    + if err != nil {
    + t.Errorf("%s got unexpected error: %s", tc.file, err.Error())
    + t.FailNow()
    + }
    + if len(anim.Frames) != tc.numFrames {
    + t.Errorf("%s expected %d frames, but got %d", tc.file, tc.numFrames, len(anim.Frames))
    + }
    + if anim.Config.Width != tc.width {
    + t.Errorf("%s expected an image width of %d, but got %d", tc.file, tc.width, anim.Config.Width)
    + }
    + if anim.Config.Height != tc.height {
    + t.Errorf("%s expected an image width of %d, but got %d", tc.file, tc.height, anim.Config.Height)
    + }
    + }
    +}
    diff --git a/webp/decode.go b/webp/decode.go
    index d6eefd5..b77e1fb 100644
    --- a/webp/decode.go
    +++ b/webp/decode.go
    @@ -19,6 +19,8 @@
    var errInvalidFormat = errors.New("webp: invalid format")

    var (
    + fccANIM = riff.FourCC{'A', 'N', 'I', 'M'}
    + fccANMF = riff.FourCC{'A', 'N', 'M', 'F'}
    fccALPH = riff.FourCC{'A', 'L', 'P', 'H'}
    fccVP8 = riff.FourCC{'V', 'P', '8', ' '}
    fccVP8L = riff.FourCC{'V', 'P', '8', 'L'}
    @@ -26,13 +28,21 @@
    fccWEBP = riff.FourCC{'W', 'E', 'B', 'P'}
    )

    -func decode(r io.Reader, configOnly bool) (image.Image, image.Config, error) {
    +func webpRiffReader(r io.Reader) (*riff.Reader, error) {
    formType, riffReader, err := riff.NewReader(r)
    if err != nil {
    - return nil, image.Config{}, err
    + return nil, err
    }
    if formType != fccWEBP {
    - return nil, image.Config{}, errInvalidFormat
    + return nil, errInvalidFormat
    + }
    + return riffReader, nil
    +}
    +
    +func decode(r io.Reader, configOnly bool) (image.Image, image.Config, error) {
    + riffReader, err := webpRiffReader(r)
    + if err != nil {
    + return nil, image.Config{}, err
    }

    var (
    @@ -116,19 +126,10 @@
    if chunkLen != 10 {
    return nil, image.Config{}, errInvalidFormat
    }
    - if _, err := io.ReadFull(chunkData, buf[:10]); err != nil {
    - return nil, image.Config{}, err
    - }
    - const (
    - animationBit = 1 << 1
    - xmpMetadataBit = 1 << 2
    - exifMetadataBit = 1 << 3
    - alphaBit = 1 << 4
    - iccProfileBit = 1 << 5
    - )
    - wantAlpha = (buf[0] & alphaBit) != 0
    - widthMinusOne = uint32(buf[4]) | uint32(buf[5])<<8 | uint32(buf[6])<<16
    - heightMinusOne = uint32(buf[7]) | uint32(buf[8])<<8 | uint32(buf[9])<<16
    + h := parseVP8XHeader(chunkData)
    + wantAlpha = h.Alpha
    + widthMinusOne = h.CanvasWidth - 1
    + heightMinusOne = h.CanvasHeight - 1
    if configOnly {
    if wantAlpha {
    return nil, image.Config{
    @@ -259,6 +260,10 @@
    return m, err
    }

    +func DecodeAnimated(r io.Reader) (*AnimatedWEBP, error) {
    + return decodeAnimated(r)
    +}
    +
    // DecodeConfig returns the color model and dimensions of a WEBP image without
    // decoding the entire image.
    func DecodeConfig(r io.Reader) (image.Config, error) {
    @@ -266,6 +271,31 @@
    return c, err
    }

    +// DecodeVP8XHeader will return the decoded VP8XHeader if this file is in the Extended File Format
    +// as defined by the webp specification. The VP8X chunk must be the first chunk of the file.
    +// If the first chunk of the file is anything else, it is not in the extended format and this
    +// will return a nil VP8XHeader. An error is only returned if the chunk is found, but invalid
    +// or a generic io.Reader error occurs.
    +func DecodeVP8XHeader(r io.Reader) (*VP8XHeader, error) {
    + riffReader, err := webpRiffReader(r)
    + if err != nil {
    + return nil, err
    + }
    + fourCC, chunkLen, chunkData, err := riffReader.Next()
    + if err != nil {
    + return nil, err
    + }
    + if fourCC != fccVP8X {
    + return nil, nil
    + }
    + if chunkLen != 10 {
    + return nil, errInvalidFormat
    + }
    +
    + h := parseVP8XHeader(chunkData)
    + return &h, nil
    +}
    +
    func init() {
    image.RegisterFormat("webp", "RIFF????WEBPVP8", Decode, DecodeConfig)
    }
    diff --git a/webp/webp.go b/webp/webp.go
    new file mode 100644
    index 0000000..0257ba8
    --- /dev/null
    +++ b/webp/webp.go
    @@ -0,0 +1,138 @@
    +package webp
    +
    +import (
    + "image"
    + "image/color"
    + "io"
    +)
    +
    +// AnimatedWEBP is the struct of a AnimatedWEBP container and the image data contained within.
    +type AnimatedWEBP struct {
    + Frames []Frame
    + Header ANIMHeader
    + Config image.Config
    +}
    +
    +type Frame struct {
    + Header ANMFHeader
    + Frame image.Image
    +}
    +
    +type VP8XHeader struct {
    + ICCProfile bool
    + Alpha bool
    + ExifMetadata bool
    + XmpMetadata bool
    + Animation bool
    + CanvasWidth uint32
    + CanvasHeight uint32
    +}
    +
    +type ALPHHeader struct {
    + Preprocessing uint8
    + FilteringMethod uint8
    + Compression uint8
    +}
    +
    +type ANIMHeader struct {
    + BackgroundColor color.Color
    + LoopCount uint16
    +}
    +
    +type ANMFHeader struct {
    + FrameX uint32
    + FrameY uint32
    + FrameWidth uint32
    + FrameHeight uint32
    + FrameDuration uint32
    + BlendBitSet bool
    + DisposalBitSet bool
    +}
    +
    +func parseALPHHeader(r io.Reader) ALPHHeader {
    + h := make([]byte, 1)
    + _, _ = io.ReadFull(r, h)
    +
    + const (
    + twoBits = byte(3)
    + )
    +
    + return ALPHHeader{
    + Preprocessing: h[0] >> 4 & twoBits,
    + FilteringMethod: h[0] >> 2 & twoBits,
    + Compression: h[0] & twoBits,
    + }
    +}
    +
    +func parseANIMHeader(r io.Reader) ANIMHeader {
    + h := make([]byte, 6)
    + _, _ = io.ReadFull(r, h)
    +
    + loopCount := uint16(h[4]) | uint16(h[5])<<8
    + bg := color.RGBA{
    + R: h[2],
    + G: h[1],
    + B: h[0],
    + A: h[3],
    + }
    +
    + return ANIMHeader{
    + BackgroundColor: bg,
    + LoopCount: loopCount,
    + }
    +}
    +
    +func parseANMFHeader(r io.Reader) ANMFHeader {
    + h := make([]byte, 16)
    + _, _ = io.ReadFull(r, h)
    +
    + const (
    + disposeBit = 1
    + blendBit = 1 << 1
    + )
    +
    + return ANMFHeader{
    + FrameX: u24(h[0:3]),
    + FrameY: u24(h[3:6]),
    + FrameWidth: u24(h[6:9]) + 1,
    + FrameHeight: u24(h[9:12]) + 1,
    + FrameDuration: u24(h[12:15]),
    + BlendBitSet: h[15]&blendBit != 0,
    + DisposalBitSet: h[15]&disposeBit != 0,
    + }
    +}
    +
    +func parseVP8XHeader(r io.Reader) VP8XHeader {
    + const (
    + anim = 1 << 1
    + xmp = 1 << 2
    + exif = 1 << 3
    + alpha = 1 << 4
    + icc = 1 << 5
    + )
    +
    + h := make([]byte, 10)
    + _, _ = io.ReadFull(r, h)
    +
    + widthMinusOne := u24(h[4:])
    + heightMinusOne := u24(h[7:])
    +
    + header := VP8XHeader{
    + ICCProfile: h[0]&icc != 0,
    + Alpha: h[0]&alpha != 0,
    + ExifMetadata: h[0]&exif != 0,
    + XmpMetadata: h[0]&xmp != 0,
    + Animation: h[0]&anim != 0,
    + CanvasWidth: widthMinusOne + 1,
    + CanvasHeight: heightMinusOne + 1,
    + }
    + return header
    +}
    +
    +func u24(b []byte) uint32 {
    + return uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16
    +}
    +
    +func u32(b []byte) uint32 {
    + return uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24
    +}

    To view, visit change 513295. To unsubscribe, or for help writing mail filters, visit settings.

    Gerrit-MessageType: newchange
    Reply all
    Reply to author
    Forward
    0 new messages