Skip to content

Commit

Permalink
[feat] Report duration for animated GIF and WebP images.
Browse files Browse the repository at this point in the history
  • Loading branch information
skidder committed Dec 1, 2024
1 parent 79f53a1 commit a46d846
Show file tree
Hide file tree
Showing 8 changed files with 172 additions and 19 deletions.
36 changes: 23 additions & 13 deletions giflib.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1132,7 +1132,7 @@ int giflib_encoder_get_output_length(giflib_encoder e)

struct GifAnimationInfo giflib_decoder_get_animation_info(const giflib_decoder d) {
// Default to 1 loop (play once) if no NETSCAPE2.0 extension is found
GifAnimationInfo info = {1, 0, 255, 255, 255, 0}; // loop_count, frame_count, bg_r, bg_g, bg_b, bg_a
GifAnimationInfo info = {1, 0, 255, 255, 255, 0, 0}; // loop_count, frame_count, bg_r, bg_g, bg_b, bg_a, duration_ms

// Create a temporary decoder to read extension blocks
giflib_decoder loopReader = new struct giflib_decoder_struct();
Expand Down Expand Up @@ -1163,18 +1163,28 @@ struct GifAnimationInfo giflib_decoder_get_animation_info(const giflib_decoder d
int ExtFunction;

if (DGifGetExtension(gif, &ExtFunction, &ExtData) == GIF_OK && ExtData != NULL) {
// Look for GraphicsControlBlock if we haven't found it yet
if (!found_gcb && ExtFunction == GRAPHICS_EXT_FUNC_CODE) {
found_gcb = true;
DGifExtensionToGCB(ExtData[0], &ExtData[1], &gcb);
// Get background color as soon as we have the GCB
uint8_t bg_red, bg_green, bg_blue, bg_alpha;
extract_background_color(gif, &gcb, &bg_red, &bg_green,
&bg_blue, &bg_alpha);
info.bg_red = bg_red;
info.bg_green = bg_green;
info.bg_blue = bg_blue;
info.bg_alpha = bg_alpha;
// Check for GraphicsControlBlock to get frame delay
if (ExtFunction == GRAPHICS_EXT_FUNC_CODE) {
GraphicsControlBlock frame_gcb;
DGifExtensionToGCB(ExtData[0], &ExtData[1], &frame_gcb);

// Add frame delay with 20ms minimum for multi-frame GIFs
int frame_delay_ms = (info.frame_count > 0 && frame_gcb.DelayTime < 2) ?
20 : frame_gcb.DelayTime * 10;
info.duration_ms += frame_delay_ms;

// If this is first GCB, handle background color
if (!found_gcb) {
found_gcb = true;
gcb = frame_gcb;
uint8_t bg_red, bg_green, bg_blue, bg_alpha;
extract_background_color(gif, &gcb, &bg_red, &bg_green,
&bg_blue, &bg_alpha);
info.bg_red = bg_red;
info.bg_green = bg_green;
info.bg_blue = bg_blue;
info.bg_alpha = bg_alpha;
}
}
// Look for NETSCAPE2.0 extension
else if (!found_loop_count &&
Expand Down
5 changes: 4 additions & 1 deletion giflib.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type gifDecoder struct {
bgGreen uint8
bgBlue uint8
bgAlpha uint8
durationMs int
}

type gifEncoder struct {
Expand Down Expand Up @@ -113,7 +114,8 @@ func (d *gifDecoder) ICC() []byte {
}

func (d *gifDecoder) Duration() time.Duration {
return time.Duration(0)
d.readAnimationInfo()
return time.Duration(d.durationMs) * time.Millisecond
}

func (d *gifDecoder) BackgroundColor() uint32 {
Expand All @@ -134,6 +136,7 @@ func (d *gifDecoder) readAnimationInfo() {
d.bgGreen = uint8(info.bg_green)
d.bgBlue = uint8(info.bg_blue)
d.bgAlpha = uint8(info.bg_alpha)
d.durationMs = int(info.duration_ms)
}
}

Expand Down
1 change: 1 addition & 0 deletions giflib.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ struct GifAnimationInfo {
int bg_green;
int bg_blue;
int bg_alpha;
int duration_ms;
};

#define GIF_DISPOSE_NONE 0
Expand Down
124 changes: 124 additions & 0 deletions giflib_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package lilliput

import (
"io"
"os"
"testing"
"time"
)

func TestGIFOperations(t *testing.T) {
t.Run("GIFDuration", testGIFDuration)
}

func testGIFDuration(t *testing.T) {
testCases := []struct {
name string
filename string
wantLoopCount int
wantFrames int
wantDuration time.Duration
description string
}{
{
name: "Standard animated GIF",
filename: "testdata/party-discord.gif",
wantLoopCount: 0, // infinite loop
wantFrames: 16,
wantDuration: time.Millisecond * 480,
description: "Basic animation with custom delays",
},
{
name: "Static GIF image",
filename: "testdata/ferry_sunset.gif",
wantLoopCount: 1, // play once
wantFrames: 1,
wantDuration: 0,
description: "Static image, no animation",
},
{
name: "Single loop GIF",
filename: "testdata/no-loop.gif",
wantLoopCount: 1, // play once
wantFrames: 44,
wantDuration: time.Millisecond * 4400,
description: "Animation that plays only once",
},
{
name: "Duplicate loop count GIF",
filename: "testdata/duplicate_number_of_loops.gif",
wantLoopCount: 2, // play twice
wantFrames: 2,
wantDuration: 0, // unable to determine duration
description: "Animation with duplicate NETSCAPE2.0 extension blocks",
},
{
name: "Background dispose GIF",
filename: "testdata/dispose_bgnd.gif",
wantLoopCount: 0, // infinite loop
wantFrames: 5,
wantDuration: time.Second * 5,
description: "Animation with background disposal method",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
testGIFImage, err := os.ReadFile(tc.filename)
if err != nil {
t.Fatalf("Failed to read gif image: %v", err)
}

decoder, err := newGifDecoder(testGIFImage)
if err != nil {
t.Fatalf("Failed to create decoder: %v", err)
}
defer decoder.Close()

// Test loop count
if got := decoder.LoopCount(); got != tc.wantLoopCount {
t.Errorf("LoopCount() = %v, want %v", got, tc.wantLoopCount)
}

// Test frame count
if got := decoder.FrameCount(); got != tc.wantFrames {
t.Errorf("FrameCount() = %v, want %v", got, tc.wantFrames)
}

// Test total duration
if got := decoder.Duration(); got != tc.wantDuration {
t.Errorf("Duration() = %v, want %v (%s)", got, tc.wantDuration, tc.description)
}

// Test per-frame durations
header, err := decoder.Header()
if err != nil {
t.Fatalf("Failed to get header: %v", err)
}

framebuffer := NewFramebuffer(header.width, header.height)
defer framebuffer.Close()

var totalDuration time.Duration
frameCount := 0
for {
err = decoder.DecodeTo(framebuffer)
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("DecodeTo failed: %v", err)
}

totalDuration += framebuffer.Duration()
frameCount++
}

// Verify total duration matches sum of frame durations
if totalDuration != tc.wantDuration {
t.Errorf("Sum of frame durations (%v) doesn't match total duration (%v)",
totalDuration, tc.wantDuration)
}
})
}
}
2 changes: 1 addition & 1 deletion lilliput_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func TestNewDecoder(t *testing.T) {
sourceFilePath: "testdata/big_buck_bunny_720_5s.webp",
wantWidth: 480,
wantHeight: 270,
wantNegativeDuration: true,
wantNegativeDuration: false,
wantAnimated: true,
},
{
Expand Down
18 changes: 17 additions & 1 deletion webp.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ struct webp_decoder_struct {
WebPMuxAnimBlend prev_frame_blend;
uint8_t* decode_buffer;
size_t decode_buffer_size;
int total_duration;
};

struct webp_encoder_struct {
Expand Down Expand Up @@ -93,10 +94,12 @@ webp_decoder webp_decoder_create(const opencv_mat buf)
return nullptr;
}

// Calculate total frame count
// Calculate total frame count and duration
d->total_frame_count = 0;
d->total_duration = 0;
do {
d->total_frame_count++;
d->total_duration += frame.duration;
WebPDataClear(&frame.bitstream);
} while (WebPMuxGetFrame(mux, d->total_frame_count + 1, &frame) == WEBP_MUX_OK);

Expand All @@ -109,6 +112,9 @@ webp_decoder webp_decoder_create(const opencv_mat buf)
d->loop_count = anim_params.loop_count;
}
d->has_animation = true;
} else {
// For static images, ensure duration is 0
d->total_duration = 0;
}

// Pre-allocate decode buffer
Expand Down Expand Up @@ -228,6 +234,16 @@ int webp_decoder_get_num_frames(const webp_decoder d)
return d ? d->total_frame_count : 0;
}

/**
* Gets the total duration of the WebP animation in milliseconds.
* @param d The webp_decoder_struct pointer.
* @return The total duration in milliseconds, 0 for static images.
*/
int webp_decoder_get_total_duration(const webp_decoder d)
{
return d ? d->total_duration : 0;
}

/**
* Gets the ICC profile data from the WebP image.
* @param d The webp_decoder_struct pointer.
Expand Down
4 changes: 1 addition & 3 deletions webp.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ type webpEncoder struct {
encoder C.webp_encoder
dstBuf []byte
icc []byte
isAnimated bool
frameIndex int
hasFlushed bool
}
Expand Down Expand Up @@ -65,7 +64,7 @@ func (d *webpDecoder) Description() string {
}

func (d *webpDecoder) Duration() time.Duration {
return time.Duration(0)
return time.Duration(C.webp_decoder_get_total_duration(d.decoder)) * time.Millisecond
}

func (d *webpDecoder) HasSubtitles() bool {
Expand All @@ -83,7 +82,6 @@ func (d *webpDecoder) hasReachedEndOfFrames() bool {

// advanceFrameIndex advances the internal frame index for the next decoding call.
func (d *webpDecoder) advanceFrameIndex() {
// Advance the frame index within the C++ decoder
C.webp_decoder_advance_frame(d.decoder)
}

Expand Down
1 change: 1 addition & 0 deletions webp.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ int webp_decoder_get_width(const webp_decoder d);
int webp_decoder_get_height(const webp_decoder d);
int webp_decoder_get_pixel_type(const webp_decoder d);
int webp_decoder_get_num_frames(const webp_decoder d);
int webp_decoder_get_total_duration(const webp_decoder d);
int webp_decoder_get_prev_frame_delay(const webp_decoder d);
int webp_decoder_get_prev_frame_dispose(const webp_decoder d);
int webp_decoder_get_prev_frame_blend(const webp_decoder d);
Expand Down

0 comments on commit a46d846

Please sign in to comment.