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();
        }
    }
}

Reply via email to