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