Reading Frames from a GIF file

Jeremy Wood mickleness at gmail.com
Fri Feb 14 21:59:43 UTC 2025


I have a generic question for the group. I’m trying to implement a 
method resembling:

public BufferedImage seek(File gifFile, int millis)

Ideally I’d like to:
1. Not add a 3rd party jar to our class path
2. Not write a new file
3. Not load the entire gif file into memory as a byte array
4. Use well-tested/stable code to handle gif frame disposal, custom 
color palettes, and any other obscure gif parsing challenges.

ImageIO doesn’t really work without a lot of intervention. It can return 
the individual frames of a GIF, but it becomes the caller’s 
responsibility to handle frame disposal/placement.

So I tried working with ToolkitImages. What I wrote (see below) 
functionally works, but it’s not an acceptable solution because it’s too 
slow. The problem now is sun.awt.image.GifImageDecoder calls 
Thread.sleep(delay). So it acts more like a player than a parser. If my 
gif file contains 10 one-second frames, then this code takes at least 9 
seconds.

This feels way too hard for such a simple ask. Is there a solution / 
toolset I’m missing? All the code I need already exists in the desktop 
module, but I seem unable to leverage it.

Regards,
  - Jeremy

———
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.metadata.IIOMetadataNode;
import java.awt.*;
import java.awt.image.*;
import java.io.*;
import java.util.ArrayList;
import java.util.Hashtable;
import java.util.Objects;
import java.util.List;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

/**
  * This uses a combination of java.awt.Image classes and ImageIO classes
  * to convert a GIF image to a series of frames.
  */
public class GifReader {

public enum FrameConsumerResult {
         CONTINUE, STOP, SKIP
     }

public final static class Info {
public final int width, height, numberOfFrames, duration;
public final boolean isLooping;

public Info(int width, int height, int numberOfFrames, int duration, boolean isLooping) {
this.width = width;
this.height = height;
this.numberOfFrames = numberOfFrames;
this.duration = duration;
this.isLooping = isLooping;
         }
     }

/**
      * Consumes information about the frames of a GIF animation.
      */
public interface GifFrameConsumer {
/**
          * This provides meta information about a GIF image.
          *
          * @return true if the reader should start supplying frame data, or false if this meta information
          * is all the consumer wanted.
          */
boolean startImage(int imageWidth, int imageHeight, int numberOfFrames, int durationMillis, boolean isLooping);

/**
          * @param frameIndex the frame index (starting at 0)
          * @param numberOfFrames the total number of frames in the GIF image.
          * @param startTimeMillis the start time of this frame (relative to the start time of the animation)
          * @param durationMillis the duration of this frame
          * @return if this returns CONTINUE then this consumer expects {@link #consumeFrame(BufferedImage, int, int, int, int, boolean)}
          * to be called next. If this returns STOP then this consumer expects to stop all reading. If this returns SKIP
          * then this consumer is not interested in this frame, but it expects to be asked about `frameIndex + 1`.
          */
default FrameConsumerResult startFrame(int frameIndex, int numberOfFrames, int startTimeMillis, int durationMillis) {
return FrameConsumerResult.CONTINUE;
         }

/**
          * Consume a new frame from a GIF image.
          *
          * @param frame an INT_ARGB image. This BufferedImage reference will be reused with each
          *              call to this method, so if you want to keep these images in memory you
          *              need to clone this image.
          * @param startTimeMillis the start time of this frame (relative to the start time of the animation)
          * @param frameIndex the current frame index (starting at 0).
          * @param numberOfFrames the total number of frames in the GIF image.
          * @param frameDurationMillis the duration of this frame in milliseconds
          * @param isDone if true then this method will not be called again.
          * @return true if the reader should continue reading additional frames, or false if the reader should
          * immediately stop. This return value is ignored if `isDone` is true.
          */
boolean consumeFrame(BufferedImage frame, int startTimeMillis, int frameDurationMillis, int frameIndex, int numberOfFrames, boolean isDone);
     }

/**
      * Read a GIF image.
      *
      * @param gifFile the GIF image file to read.
      * @param waitUntilFinished if true then this method will not return until the GifFrameConsumer
      *                          has received every frame.
      * @param frameConsumer the consumer that will consume the image data.
      */
public void read(final File gifFile, final boolean waitUntilFinished, final GifFrameConsumer frameConsumer) throws IOException {
         Objects.requireNonNull(frameConsumer);
final Semaphore semaphore = new Semaphore(1);
         semaphore.acquireUninterruptibly();

try (FileInputStream gifFileIn = new FileInputStream(gifFile)) {
             List<Integer> frameDurationsMillis = readFrameDurationMillis(gifFileIn);
             Image image = Toolkit.getDefaultToolkit().createImage(gifFile.getPath());
             ImageConsumer consumer = new ImageConsumer() {
private BufferedImage bi;
private boolean isActive = true;
private int frameCtr, imageWidth, imageHeight, currentFrameStartTime;
private boolean ignoreCurrentFrame;

                 @Override
public void setDimensions(int width, int height) {
                     imageWidth = width;
                     imageHeight = height;

// if this gif loops:
                     // the sun.awt.image.GifImageDecoder calls ImageFetcher.startingAnimation, which
                     // changes the name of this thread. We don't know how many times it's supposed
                     // to loop, but in my experience gifs either don't loop at all or they loop forever;
                     // they aren't asked to loop N-many times anymore
boolean isLooping = Thread.currentThread().getName().contains("Image Animator");

try {
int totalDuration = 0;
for (int frameDuration : frameDurationsMillis)
                             totalDuration += frameDuration;

if (!frameConsumer.startImage(width, height, frameDurationsMillis.size(), totalDuration, isLooping))
                             stop(null);
                     } catch(Exception e) {
                         stop(e);
                     }
                 }

                 @Override
public void setProperties(Hashtable<?, ?> props) {}

                 @Override
public void setColorModel(ColorModel model) {}

                 @Override
public void setHints(int hintflags) {
// this is called before every frame starts:
int frameDuration = frameDurationsMillis.get(frameCtr);
                     FrameConsumerResult r = frameConsumer.startFrame(frameCtr, frameDurationsMillis.size(), currentFrameStartTime, frameDuration);
if (r == FrameConsumerResult.STOP) {
                         stop(null);
                     } else {
                         ignoreCurrentFrame = r == FrameConsumerResult.SKIP;
                     }
                 }

private int[] argbRow;
private int[] colorModelRGBs;
private IndexColorModel lastModel;

                 @Override
public void setPixels(final int x, final int y, final int w, final int h,
final ColorModel model, final byte[] pixels, final int off, final int scansize) {
// Even if ignoreCurrentFrame is true we still need to update the image every iteration.
                     // (This is because each frame in a GIF has a "disposal method", and some of them rely
                     // on the previous frame.) In theory we *may* be able to skip this method *sometimes*
                     // depending on the disposal methods in use, but that would take some more research.

try {
// ImageConsumer javadoc says:
                         // Pixel (m,n) is stored in the pixels array at index (n * scansize + m + off)

final int yMax = y + h;
final int xMax = x + w;

if (model instanceof IndexColorModel icm) {
if (icm != lastModel) {
                                 colorModelRGBs = new int[icm.getMapSize()];
                                 icm.getRGBs(colorModelRGBs);
                             }
                             lastModel = icm;
                         } else {
                             colorModelRGBs = null;
                         }

if (bi == null) {
                             bi = new BufferedImage(imageWidth, imageHeight, BufferedImage.TYPE_INT_ARGB);
                             argbRow = new int[imageWidth];
                         }

for (int y_ = y; y_ < yMax; y_++) {
// we're not told to use (off-x), but empirically this is what we get/need:
int i = y_ * scansize + x + (off - x);
for (int x_ = x; x_ < xMax; x_++, i++) {
int pixel = pixels[i] & 0xff;
if (colorModelRGBs != null) {
                                     argbRow[x_ - x] = colorModelRGBs[pixel];
                                 } else {
// I don't think we ever resort to this:
argbRow[x_ - x] = 0xff000000 + (model.getRed(pixel) << 16) + (model.getGreen(pixel) << 8) + (model.getBlue(pixel));
                                 }
                             }
                             bi.getRaster().setDataElements(x, y_, w, 1, argbRow);
                         }
                     } catch(RuntimeException e) {
// we don't expect this to happen, but if something goes wrong nobody else
                         // will print our stacktrace for us:
stop(e);
throw e;
                     }
                 }

                 @Override
public void setPixels(int x, int y, int w, int h, ColorModel model, int[] pixels, int off, int scansize) {
// we never expect this for a GIF image
throw new UnsupportedOperationException();
                 }

                 @Override
public void imageComplete(int status) {
try {
int numberOfFrames = frameDurationsMillis.size();
int frameDuration = frameDurationsMillis.get(frameCtr);
boolean consumeResult;
if (ignoreCurrentFrame) {
                             consumeResult = true;
                         } else {
                             consumeResult = frameConsumer.consumeFrame(bi, currentFrameStartTime, frameDuration,
                                     frameCtr, numberOfFrames, frameCtr + 1 >= numberOfFrames);
                         }
                         frameCtr++;
                         currentFrameStartTime += frameDuration;

// if we don't remove this ImageConsumer the animating thread will loop forever
if (frameCtr == numberOfFrames || !consumeResult)
                             stop(null);
                     } catch(Exception e) {
                         stop(e);
                     }
                 }

private void stop(Exception e) {
synchronized (this) {
if (!isActive)
return;
                         isActive = false;
                     }
if (e != null)
                         e.printStackTrace();
                     image.getSource().removeConsumer(this);
                     image.flush();
if (bi != null)
                         bi.flush();
                     semaphore.release();

                 }
             };
             image.getSource().startProduction(consumer);
         }
if (waitUntilFinished)
             semaphore.acquireUninterruptibly();
     }

/**
      * Return the frame at a given time in an animation.
      */
public BufferedImage seek(File gifFile, int millis) throws IOException {
         AtomicInteger seekTime = new AtomicInteger();
         AtomicReference<BufferedImage> returnValue = new AtomicReference<>();
         read(gifFile, true, new GifFrameConsumer() {
             @Override
public boolean startImage(int imageWidth, int imageHeight, int numberOfFrames, int durationMillis, boolean isLooping) {
                 seekTime.set(millis%durationMillis);
return true;
             }

             @Override
public FrameConsumerResult startFrame(int frameIndex, int numberOfFrames, int startTimeMillis, int durationMillis) {
if (numberOfFrames == 1 ||
                         (startTimeMillis <= seekTime.get() && seekTime.get() < startTimeMillis + durationMillis))
return FrameConsumerResult.CONTINUE;

if (startTimeMillis + durationMillis <= seekTime.get())
return FrameConsumerResult.SKIP;
return FrameConsumerResult.STOP;
             }

             @Override
public boolean consumeFrame(BufferedImage frame, int startTimeMillis, int frameDurationMillis, int frameIndex, int numberOfFrames, boolean isDone) {
                 returnValue.set(frame);
return false;
             }
         });
return returnValue.get();
     }

/**
      * Return basic information about a GIF image/animation.
      */
public Info getInfo(File gifFile) throws IOException {
         AtomicReference<Info> returnValue = new AtomicReference<>();
         read(gifFile, true, new GifFrameConsumer() {
             @Override
public boolean startImage(int imageWidth, int imageHeight, int numberOfFrames, int durationMillis, boolean isLooping) {
                 returnValue.set(new Info(imageWidth, imageHeight, numberOfFrames, durationMillis, isLooping));
return false;
             }

             @Override
public boolean consumeFrame(BufferedImage frame, int startTimeMillis, int frameDurationMillis, int frameIndex, int numberOfFrames, boolean isDone) {
return false;
             }
         });
return returnValue.get();
     }

/**
      * Read the frame durations of a gif. This relies on ImageIO classes.
      */
private List<Integer> readFrameDurationMillis(InputStream stream) throws IOException {
         ImageReader reader = ImageIO.getImageReadersByFormatName("gif").next();
try {
             reader.setInput(ImageIO.createImageInputStream(stream));
int frameCount = reader.getNumImages(true);
             List<Integer> frameDurations = new ArrayList<>(frameCount);
for (int frameIndex = 0; frameIndex < frameCount; frameIndex++) {
                 IIOMetadataNode root = (IIOMetadataNode) reader.getImageMetadata(frameIndex).getAsTree("javax_imageio_gif_image_1.0");
                 IIOMetadataNode gce = (IIOMetadataNode) root.getElementsByTagName("GraphicControlExtension").item(0);
int delay = Integer.parseInt(gce.getAttribute("delayTime"));
                 frameDurations.add( delay * 10 );
             }
return frameDurations;
         } finally {
             reader.dispose();
         }
     }
}
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://mail.openjdk.org/pipermail/client-libs-dev/attachments/20250214/78ded9e7/attachment-0001.htm>


More information about the client-libs-dev mailing list