Javaで画面操作説明用GIF動画を作成する


Posted on 2021/12/24 at 2:20


画面操作を録画してGIF動画を作成したいなと検索したところ「ScreenToGif」というフリーソフトがあるらしく、ネット上の口コミも良さそうでした。

ただ、私はJAVA使いということでJAVA製のキャプチャソフトを探してみるも見つからず・・・。 無いなら自分で作ってみようという事で、色々試行錯誤しようやく動くものが出来上がったので、サンプルコード置いておきます。

機能としてはスクリーンキャプチャを指定したフレーム分を取得して、GIF動画にします。 PCのスペックにもよると思いますが、私の環境では6fpsぐらいしか出せませんでした。それでも操作説明するには十分かと思います。 以下にサンプル動画ありますので、評価してみてください。

GIF動画サンプル

サンプルプログラムにより作成したGIF動画です。最初は8MBぐらいの容量となってしまいますが、 GIMPで必要な部分のみカットしてフィルター処理することで454kBまで下げることができました。

GIF動画サンプル

サンプルコード

  
import java.awt.AWTException;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.Image;
import java.awt.MouseInfo;
import java.awt.Point;
import java.awt.PointerInfo;
import java.awt.Rectangle;
import java.awt.Robot;
import java.awt.Toolkit;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageTypeSpecifier;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.metadata.IIOMetadata;
import javax.imageio.metadata.IIOMetadataNode;
import javax.imageio.stream.ImageOutputStream;

import org.w3c.dom.Node;

public class GifCapture {

	private List<Integer> list;

	private ImageWriter iw;
	private Robot robot;
	private Rectangle screenSize;
	private IIOMetadataNode gce;
	private IIOMetadataNode aes = null;
	private IIOMetadata metadata;
	private boolean isLoop = true;
	private int loopCount = 0;	//ループ回数(0:無限ループ)
	private int fileNo = 1;
    
	public static void main(String[] args) {
		try {
			GifCapture capture = new GifCapture();
			capture.captureStart();
		} catch (Exception e) {
			e.printStackTrace();
		}
	}

	public GifCapture() throws AWTException, IOException {
		File tmpDir = new File("tmp/");
		if (!tmpDir.exists())
			tmpDir.mkdir();
		robot = new Robot();
		screenSize = new Rectangle(Toolkit.getDefaultToolkit().getScreenSize());
		list = new ArrayList<Integer>();
		Iterator<ImageWriter> it = ImageIO.getImageWritersByFormatName("gif");
		iw = it.hasNext() ? it.next() : null;
		ImageOutputStream ios = ImageIO.createImageOutputStream(new File("tmp/capture.gif"));
		iw.setOutput(ios);
		iw.prepareWriteSequence(null);

		gce = new IIOMetadataNode("GraphicControlExtension");
		gce.setAttribute("disposalMethod", "none");
		gce.setAttribute("userInputFlag", "FALSE");
		gce.setAttribute("transparentColorFlag", "FALSE");
		gce.setAttribute("transparentColorIndex", "0");
		gce.setAttribute("delayTime", "15");
		
		// Loop Control
		if (isLoop) {
			IIOMetadataNode ae = new IIOMetadataNode("ApplicationExtension");
			ae.setAttribute("applicationID", "NETSCAPE");
			ae.setAttribute("authenticationCode", "2.0");
			ae.setUserObject(new byte[] {0x1, (byte)loopCount, 0x0});
			aes = new IIOMetadataNode("ApplicationExtensions");
			aes.appendChild(ae);
		}
	}
	int second = 1;
	public void captureStart() throws Exception {
		long startTime = System.currentTimeMillis();
		ExecutorService th1 = Executors.newFixedThreadPool(3);
		for (int i=0; i<60; i++) {
			Capture cp = new Capture();
			th1.execute(cp);
		}
		th1.shutdown();
		while (list.isEmpty()) {
			Thread.sleep(100);
		}
		ExecutorService th2 = Executors.newSingleThreadExecutor();
		th2.execute(new Runnable() {
			@Override
			public void run() {
				ImageWriteParam iwp = iw.getDefaultWriteParam();
				int index = 0;
				while (index == 0 || index < list.size()) {
					int fileNo = list.get(index);
					String fileNoStr = Integer.toString(fileNo);
					String zero = "";
					for (int j=4; j>fileNoStr.length(); j--)
						zero += "0";
					String filePath = "tmp/" + zero + fileNoStr+ ".gif";
					File gif = new File(filePath);
					if (gif.exists()) {
						BufferedImage buff;
						try {
							buff = ImageIO.read(gif);
							metadata = iw.getDefaultImageMetadata(
				      			      new ImageTypeSpecifier(buff), iwp);
				      		String metaFormat = metadata.getNativeMetadataFormatName();
				      		Node root = metadata.getAsTree(metaFormat);
				      		root.appendChild(gce);
				      		if (aes != null)
				      			root.appendChild(aes);
				      		metadata.setFromTree(metaFormat, root);
				    		iw.writeToSequence(new IIOImage(buff, null, metadata), null);
							System.out.println("writeToSequence : " + gif.getAbsolutePath());
							gif.deleteOnExit();
							index++;
						} catch (IOException e) {
							e.printStackTrace();
							break;
						}
					} else {
						System.out.println("not found : " + gif.getAbsolutePath() + " - continued");
						try {
							Thread.sleep(100);
						} catch (InterruptedException e) {}
					}
				}
				try {
					iw.endWriteSequence();
					System.out.println("endWriteSequence : " + (System.currentTimeMillis() - startTime) + "ms");
				} catch (IOException e) {
					e.printStackTrace();
				}
			}
		});
		th2.shutdown();
	}
    
	private synchronized CaptureImage getCaptureImage() {
		String fileNoStr = Integer.toString(fileNo);
		String zero = "";
		for (int j=4; j>fileNoStr.length(); j--)
			zero += "0";
		String filePath = "tmp/" + zero + fileNoStr+ ".tmp";
		list.add(fileNo);
		fileNo++;
		BufferedImage buff = robot.createScreenCapture(screenSize);
		PointerInfo inf = MouseInfo.getPointerInfo();
		Point mp = inf.getLocation();
		int x = (int) mp.getLocation().getX();
		int y = (int) mp.getLocation().getY();
		int w = buff.getWidth() / 2;
		int h = buff.getHeight() / 2;
		Graphics2D g = buff.createGraphics();
		g.setColor(Color.WHITE);
		g.fillOval(x, y, 7, 7);
		g.setColor(Color.BLACK);
		g.drawOval(x, y, 8, 8);
		BufferedImage to = new BufferedImage(
				w, h,  BufferedImage.TYPE_3BYTE_BGR);
		to.getGraphics().drawImage(buff.getScaledInstance(
				w, h, Image.SCALE_AREA_AVERAGING), 
				0, 0, w, h, null);
		CaptureImage capture = new CaptureImage();
		capture.setCapture(to);
		capture.setFilePath(filePath);
		return capture;
	}
	
	class Capture implements Runnable {
		@Override
		public void run() {
			try {
				CaptureImage capture = getCaptureImage();
				String filePath = capture.getFilePath();
				BufferedImage buff = capture.getBufferedImage();
				File tmp = new File(filePath);
				tmp.deleteOnExit();
				ImageIO.write(buff, "gif", tmp);
				String renamePath = filePath.replace(".tmp", ".gif");
				File gif = new File(renamePath);
				gif.deleteOnExit();
				System.out.println("rename: " + tmp.renameTo(gif) + "=> " + gif.getName());
				tmp.delete();
			} catch (Exception e) {
				e.printStackTrace();
			}
		}
	}
	
	class CaptureImage {
		private String filePath = null;
		private BufferedImage buff = null;
		public String getFilePath() {
			return filePath;
		}
		public void setFilePath(String filePath) {
			this.filePath = filePath;
		}
		public BufferedImage getBufferedImage() {
			return buff;
		}
		public void setCapture(BufferedImage capture) {
			this.buff = capture;
		}
	}
}
  

サンプルプログラムの説明

		
BufferedImage buff = robot.createScreenCapture(screenSize);
		
		

ここでスクリーンキャプチャをBufferedImageとして取得するのですが、これをwriteToSequence()でGIFに書き込む処理が重く、最初は3fpsほどしか出せませんでした。

そこでtmpというディレクトリを作成し、そこに一時ファイルとして1枚のGIF画像を作成し、別スレッドからファイルを読み込んでGIFに書き込む方法に変更しました。

それでもやっと5~6fpsぐらいですが・・・。

		
gce.setAttribute("delayTime", "15");
		
		

GIF動画にはヘッダ情報を入れることができ、delayTimeはフレーム再生時間で単位は1/100秒となります。

今回のサンプルでは150m秒となります。色々試しましたがこれが一番自然に仕上がりました。

		
for (int i=0; i<60; i++) {
	Capture cp = new Capture();
	th1.execute(cp);
}
		
		

ここで何フレームキャプチャするかを設定します。私の環境では60フレーム撮り終わるのに大体11秒かかりましたので、5~6fpsというところでしょうか。

		
PointerInfo inf = MouseInfo.getPointerInfo();
Point mp = inf.getLocation();
int x = (int) mp.getLocation().getX();
int y = (int) mp.getLocation().getY();
Graphics2D g = buff.createGraphics();
g.setColor(Color.WHITE);
g.fillOval(x, y, 7, 7);
g.setColor(Color.BLACK);
g.drawOval(x, y, 8, 8);
		
		

キャプチャ画像にはマウスカーソルが表示されません。なので、マウス位置を取得してfillOvalで黒ふちの塗りつぶし白の丸を描画しています。

GIF動画の容量圧縮

プログラムにより出力されたGIF動画は容量大きめで出力されるため、そのままサイトに貼り付けることはおすすめできません。 それならmp4にして自動再生にした方が絵もきれいでいいと思います。

私はGIMPを使用して必要な部分のみ切り取りし、フィルター>アニメーション>GIF用最適化をすることで、容量を圧縮しました。

GIMPフィルター処理

まとめ

数秒程度の簡単な説明動画であれば以下の点でGIF動画がおすすめです。

  • きちんと処理をすればファイルサイズが軽量
  • スマートフォンやPCなど、ほとんどの環境で再生可能
  • 無限ループ再生が可能

しかし、GIFであるため最大256色までしか使えないため、実写には向かない。音声が入れられないなどの大きなデメリットもあるため、 動画の長さ、種類によって使い分ける必要があります。