ligmen: include frame times in image ids

This commit is contained in:
Branden J Brown 2025-05-28 19:47:52 -04:00
parent c551a5f61b
commit fe5a368249
3 changed files with 128 additions and 37 deletions

24
ligmen/formats.go Normal file
View File

@ -0,0 +1,24 @@
package ligmen
import (
"image"
"time"
)
// Image is a potentially animated image.
type Image[Frame image.Image] struct {
// Frames is animation's frame images.
// An image that is not animated has a single frame.
Frames []Frame
// Times is the duration of each frame.
// An image that is not animated has a single frame duration of 0.
Times []time.Duration
}
// Static is a helper to create an Image with a single frame.
func Static[Frame image.Image](img Frame) Image[Frame] {
return Image[Frame]{
Frames: []Frame{img},
Times: []time.Duration{0},
}
}

View File

@ -14,14 +14,14 @@ import (
type ID [28]byte type ID [28]byte
// Hash computes the content ID for an image or animation. // Hash computes the content ID for an image or animation.
func Hash[Image image.Image](frames []Image) ID { func Hash[Frame image.Image](anim Image[Frame]) ID {
// NOTE(zephyr): We assume animations always loop forever. // NOTE(zephyr): We assume animations always loop forever.
// The behavior in browsers when they don't is that every instance shares a // The behavior in browsers when they don't is that every instance shares a
// single loop count, so they quickly stop repeating as posted. // single loop count, so they quickly stop repeating as posted.
h := sha3.New224() h := sha3.New224()
d := make([]byte, 8, 28) d := make([]byte, 8, 28)
var bounds image.Rectangle var bounds image.Rectangle
for _, f := range frames { for _, f := range anim.Frames {
if bounds == (image.Rectangle{}) { if bounds == (image.Rectangle{}) {
bounds = f.Bounds().Canon() bounds = f.Bounds().Canon()
} }
@ -37,10 +37,12 @@ func Hash[Image image.Image](frames []Image) ID {
h.Write(d) h.Write(d)
} }
} }
d = d[:12] for _, t := range anim.Times {
binary.BigEndian.PutUint64(d, uint64(t.Nanoseconds()))
h.Write(d)
}
binary.BigEndian.PutUint32(d, uint32(bounds.Dx())) binary.BigEndian.PutUint32(d, uint32(bounds.Dx()))
binary.BigEndian.PutUint32(d[4:], uint32(bounds.Dy())) binary.BigEndian.PutUint32(d[4:], uint32(bounds.Dy()))
binary.BigEndian.PutUint32(d[8:], uint32(len(frames)))
h.Write(d) h.Write(d)
return ID(h.Sum(d[:0])) return ID(h.Sum(d[:0]))
} }

View File

@ -4,6 +4,7 @@ import (
"image" "image"
"image/color" "image/color"
"testing" "testing"
"time"
"ligmotes.com/ligmen" "ligmotes.com/ligmen"
) )
@ -36,70 +37,133 @@ func (img *testimg) At(x int, y int) color.Color {
func TestHash(t *testing.T) { func TestHash(t *testing.T) {
// We test that hashing a number of similar images doesn't produce collisions. // We test that hashing a number of similar images doesn't produce collisions.
cases := []struct { cases := []struct {
name string name string
frames []image.RGBA64Image anim ligmen.Image[image.RGBA64Image]
}{ }{
{ {
name: "clear-square", name: "clear-square",
frames: []image.RGBA64Image{ anim: ligmen.Static[image.RGBA64Image](&testimg{px: []color.RGBA64{{}, {}, {}, {}}, w: 2, h: 2}),
&testimg{px: []color.RGBA64{{}, {}, {}, {}}, w: 2, h: 2},
},
}, },
{ {
name: "topleft-square", name: "topleft-square",
frames: []image.RGBA64Image{ anim: ligmen.Static[image.RGBA64Image](&testimg{px: []color.RGBA64{{A: 1}, {}, {}, {}}, w: 2, h: 2}),
&testimg{px: []color.RGBA64{{A: 1}, {}, {}, {}}, w: 2, h: 2},
},
}, },
{ {
name: "topright-square", name: "topright-square",
frames: []image.RGBA64Image{ anim: ligmen.Static[image.RGBA64Image](&testimg{px: []color.RGBA64{{}, {A: 1}, {}, {}}, w: 2, h: 2}),
&testimg{px: []color.RGBA64{{}, {A: 1}, {}, {}}, w: 2, h: 2},
},
}, },
{ {
name: "botleft-square", name: "botleft-square",
frames: []image.RGBA64Image{ anim: ligmen.Static[image.RGBA64Image](&testimg{px: []color.RGBA64{{}, {}, {A: 1}, {}}, w: 2, h: 2}),
&testimg{px: []color.RGBA64{{}, {}, {A: 1}, {}}, w: 2, h: 2},
},
}, },
{ {
name: "botright-square", name: "botright-square",
frames: []image.RGBA64Image{ anim: ligmen.Static[image.RGBA64Image](&testimg{px: []color.RGBA64{{}, {}, {}, {A: 1}}, w: 2, h: 2}),
&testimg{px: []color.RGBA64{{}, {}, {}, {A: 1}}, w: 2, h: 2},
},
}, },
{ {
name: "all-square", name: "all-square",
frames: []image.RGBA64Image{ anim: ligmen.Static[image.RGBA64Image](&testimg{px: []color.RGBA64{{A: 1}, {A: 1}, {A: 1}, {A: 1}}, w: 2, h: 2}),
&testimg{px: []color.RGBA64{{A: 1}, {A: 1}, {A: 1}, {A: 1}}, w: 2, h: 2}, },
}, {
name: "red-square",
anim: ligmen.Static[image.RGBA64Image](&testimg{px: []color.RGBA64{{R: 1, A: 1}, {}, {}, {}}, w: 2, h: 2}),
},
{
name: "green-square",
anim: ligmen.Static[image.RGBA64Image](&testimg{px: []color.RGBA64{{G: 1, A: 1}, {}, {}, {}}, w: 2, h: 2}),
},
{
name: "blue-square",
anim: ligmen.Static[image.RGBA64Image](&testimg{px: []color.RGBA64{{B: 1, A: 1}, {}, {}, {}}, w: 2, h: 2}),
},
{
name: "gray-square",
anim: ligmen.Static[image.RGBA64Image](&testimg{px: []color.RGBA64{{R: 1, G: 1, B: 1, A: 1}, {}, {}, {}}, w: 2, h: 2}),
}, },
{ {
name: "row", name: "row",
frames: []image.RGBA64Image{ anim: ligmen.Static[image.RGBA64Image](&testimg{px: []color.RGBA64{{}, {}, {}, {}}, w: 4, h: 1}),
&testimg{px: []color.RGBA64{{}, {}, {}, {}}, w: 4, h: 1},
},
}, },
{ {
name: "col", name: "col",
frames: []image.RGBA64Image{ anim: ligmen.Static[image.RGBA64Image](&testimg{px: []color.RGBA64{{}, {}, {}, {}}, w: 1, h: 4}),
&testimg{px: []color.RGBA64{{}, {}, {}, {}}, w: 1, h: 4},
},
}, },
{ {
name: "frames", name: "frames",
frames: []image.RGBA64Image{ anim: ligmen.Image[image.RGBA64Image]{
&testimg{px: []color.RGBA64{{}}, w: 1, h: 1}, Frames: []image.RGBA64Image{
&testimg{px: []color.RGBA64{{}}, w: 1, h: 1}, &testimg{px: []color.RGBA64{{}}, w: 1, h: 1},
&testimg{px: []color.RGBA64{{}}, w: 1, h: 1}, &testimg{px: []color.RGBA64{{}}, w: 1, h: 1},
&testimg{px: []color.RGBA64{{}}, w: 1, h: 1}, &testimg{px: []color.RGBA64{{}}, w: 1, h: 1},
&testimg{px: []color.RGBA64{{}}, w: 1, h: 1},
},
Times: []time.Duration{1, 1, 1, 1},
},
},
{
name: "frames-first",
anim: ligmen.Image[image.RGBA64Image]{
Frames: []image.RGBA64Image{
&testimg{px: []color.RGBA64{{}}, w: 1, h: 1},
&testimg{px: []color.RGBA64{{}}, w: 1, h: 1},
&testimg{px: []color.RGBA64{{}}, w: 1, h: 1},
&testimg{px: []color.RGBA64{{}}, w: 1, h: 1},
},
Times: []time.Duration{2, 1, 1, 1},
},
},
{
name: "frames-second",
anim: ligmen.Image[image.RGBA64Image]{
Frames: []image.RGBA64Image{
&testimg{px: []color.RGBA64{{}}, w: 1, h: 1},
&testimg{px: []color.RGBA64{{}}, w: 1, h: 1},
&testimg{px: []color.RGBA64{{}}, w: 1, h: 1},
&testimg{px: []color.RGBA64{{}}, w: 1, h: 1},
},
Times: []time.Duration{1, 2, 1, 1},
},
},
{
name: "frames-third",
anim: ligmen.Image[image.RGBA64Image]{
Frames: []image.RGBA64Image{
&testimg{px: []color.RGBA64{{}}, w: 1, h: 1},
&testimg{px: []color.RGBA64{{}}, w: 1, h: 1},
&testimg{px: []color.RGBA64{{}}, w: 1, h: 1},
&testimg{px: []color.RGBA64{{}}, w: 1, h: 1},
},
Times: []time.Duration{1, 1, 2, 1},
},
},
{
name: "frames-fourth",
anim: ligmen.Image[image.RGBA64Image]{
Frames: []image.RGBA64Image{
&testimg{px: []color.RGBA64{{}}, w: 1, h: 1},
&testimg{px: []color.RGBA64{{}}, w: 1, h: 1},
&testimg{px: []color.RGBA64{{}}, w: 1, h: 1},
&testimg{px: []color.RGBA64{{}}, w: 1, h: 1},
},
Times: []time.Duration{1, 1, 1, 2},
},
},
{
name: "frames-all",
anim: ligmen.Image[image.RGBA64Image]{
Frames: []image.RGBA64Image{
&testimg{px: []color.RGBA64{{}}, w: 1, h: 1},
&testimg{px: []color.RGBA64{{}}, w: 1, h: 1},
&testimg{px: []color.RGBA64{{}}, w: 1, h: 1},
&testimg{px: []color.RGBA64{{}}, w: 1, h: 1},
},
Times: []time.Duration{2, 2, 2, 2},
}, },
}, },
} }
m := make(map[ligmen.ID]string) m := make(map[ligmen.ID]string)
for _, c := range cases { for _, c := range cases {
id := ligmen.Hash(c.frames) id := ligmen.Hash(c.anim)
if had, ok := m[id]; ok { if had, ok := m[id]; ok {
t.Errorf("%s collides with %s @ %v", c.name, had, id) t.Errorf("%s collides with %s @ %v", c.name, had, id)
continue continue
@ -109,7 +173,8 @@ func TestHash(t *testing.T) {
} }
func TestIDRoundTrip(t *testing.T) { func TestIDRoundTrip(t *testing.T) {
id := ligmen.Hash([]*testimg{{px: []color.RGBA64{{}, {}, {}, {}}, w: 2, h: 2}}) img := ligmen.Static(&testimg{px: []color.RGBA64{{}, {}, {}, {}}, w: 2, h: 2})
id := ligmen.Hash(img)
if id == (ligmen.ID{}) { if id == (ligmen.ID{}) {
t.Error("empty id for image") t.Error("empty id for image")
} }