Reading Frames from a GIF file
David Alayachew
davidalayachew at gmail.com
Fri Feb 14 22:57:10 UTC 2025
Hey Jeremy,
Yeah, that's not even close to the performance that Java is capable of.
If you want to see something that is used in Production using only Java
code, look at iCafe. The code is all open source, and I know it can do what
you are trying to do because I did it too. It'll give you an idea of what
is going wrong here.
On Fri, Feb 14, 2025, 5:04 PM Jeremy Wood <mickleness at gmail.com> wrote:
> 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/817ae1df/attachment-0001.htm>
More information about the client-libs-dev
mailing list