xeij/GIFAnimation.java
//========================================================================================
// GIFAnimation.java
// en:GIF animation recording
// ja:GIFアニメーション録画
// Copyright (C) 2003-2026 Makoto Kamada
//
// This file is part of the XEiJ (X68000 Emulator in Java).
// You can use, modify and redistribute the XEiJ if the conditions are met.
// Read the XEiJ License for more details.
// https://stdkmd.net/xeij/
//========================================================================================
package xeij;
import java.awt.*; //Graphics2D,RenderingHints
import java.awt.event.*; //ActionEvent,ActionListener
import java.awt.image.*; //BufferedImage,DataBufferInt
import java.io.*; //File
import java.util.*; //HashSet,Timer
import javax.imageio.*; //ImageIO
import javax.imageio.metadata.*; //IIOMetadata
import javax.imageio.stream.*; //ImageOutputStream
import javax.swing.*; //JSpinner,SpinnerNumberModel
import javax.swing.event.*; //ChangeEvent,ChangeListener
import org.w3c.dom.*; //Node
public class GIFAnimation {
//GIFアニメーション録画
//待ち時間
// 例えばゲームの画面を録画するとき、ポーズをかけた状態で録画ボタンを押してポーズを解除してプレイを再開した後に録画を開始できる
public static final int GIF_WAITING_TIME_MIN = 0; //待ち時間(s)の最小値
public static final int GIF_WAITING_TIME_MAX = 30; //待ち時間(s)の最大値
public static final int GIF_RECORDING_TIME_MIN = 1; //録画時間(s)の最小値
public static final int GIF_RECORDING_TIME_MAX = 30; //録画時間(s)の最大値
public static final int GIF_MAGNIFICATION_MIN = 10; //倍率(%)の最小値
public static final int GIF_MAGNIFICATION_MAX = 200; //倍率(%)の最大値
public static int gifWaitingTime; //待ち時間(s)
public static int gifRecordingTime; //録画時間(s)
public static int gifMagnification; //倍率(%)
public static Object gifInterpolation; //補間アルゴリズム
public static SpinnerNumberModel gifWaitingTimeModel; //待ち時間(s)のスピナーモデル
public static SpinnerNumberModel gifRecordingTimeModel; //録画時間(s)のスピナーモデル
public static SpinnerNumberModel gifMagnificationModel; //倍率(%)のスピナーモデル
public static JMenuItem gifStartRecordingMenuItem; //録画開始メニューアイテム
public static JMenu gifSettingsMenu; //GIFアニメーション録画設定メニュー
public static java.util.Timer gifTimer; //出力用のスレッド
public static int gifScreenWidth; //pnlScreenWidthのコピー
public static int gifScreenHeight; //pnlScreenHeightのコピー
public static int gifStretchWidth; //pnlStretchWidthのコピー
public static int gifStretchHeight; //pnlStretchHeightのコピー
public static int gifStereoscopicFactor; //pnlStereoscopicFactorのコピー
public static boolean gifStereoscopicOn; //pnlStereoscopicOnのコピー
public static int gifStereoscopicMethod; //pnlStereoscopicMethodのコピー
public static double gifDelayTime; //フレームの間隔(10ms単位)
public static int gifWaitingFrames; //待ち時間のフレーム数
public static int gifRecordingFrames; //録画時間のフレーム数
public static int[] gifBuffer; //フレームバッファ
public static int gifPointer; //フレームバッファのポインタ
public static int gifWaitingCounter; //待ち時間のフレームカウンタ
public static int gifRecordingCounter; //録画時間のフレームカウンタ
public static boolean gifNowRecording; //true=録画中
//gifInit ()
// 初期化
public static void gifInit () {
gifWaitingTime = Math.max (GIF_WAITING_TIME_MIN, Math.min (GIF_WAITING_TIME_MAX, Settings.sgsGetInt ("gifwaitingtime")));
gifRecordingTime = Math.max (GIF_RECORDING_TIME_MIN, Math.min (GIF_RECORDING_TIME_MAX, Settings.sgsGetInt ("gifrecordingtime")));
gifMagnification = Math.max (GIF_MAGNIFICATION_MIN, Math.min (GIF_MAGNIFICATION_MAX, Settings.sgsGetInt ("gifmagnification")));
switch (Settings.sgsGetString ("gifinterpolation").toLowerCase ()) {
case "nearest": //最近傍補間
gifInterpolation = RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR;
break;
case "bilinear": //線形補間
gifInterpolation = RenderingHints.VALUE_INTERPOLATION_BILINEAR;
break;
case "bicubic": //三次補間
gifInterpolation = RenderingHints.VALUE_INTERPOLATION_BICUBIC;
break;
default:
gifInterpolation = RenderingHints.VALUE_INTERPOLATION_BILINEAR;
}
gifWaitingTimeModel = new SpinnerNumberModel (gifWaitingTime, GIF_WAITING_TIME_MIN, GIF_WAITING_TIME_MAX, 1);
gifRecordingTimeModel = new SpinnerNumberModel (gifRecordingTime, GIF_RECORDING_TIME_MIN, GIF_RECORDING_TIME_MAX, 1);
gifMagnificationModel = new SpinnerNumberModel (gifMagnification, GIF_MAGNIFICATION_MIN, GIF_MAGNIFICATION_MAX, 1);
ButtonGroup interpolationGroup = new ButtonGroup ();
gifStartRecordingMenuItem =
Multilingual.mlnText (
ComponentFactory.createMenuItem (
"Start recording",
new ActionListener () {
@Override public void actionPerformed (ActionEvent ae) {
gifStartRecording ();
}
}),
"ja", "録画開始");
gifSettingsMenu =
Multilingual.mlnText (
ComponentFactory.createMenu (
"GIF animation recording settings",
ComponentFactory.createHorizontalBox (
Multilingual.mlnText (ComponentFactory.createLabel ("Waiting time"), "ja", "待ち時間"),
Box.createHorizontalGlue ()
),
ComponentFactory.createHorizontalBox (
Box.createHorizontalStrut (20),
ComponentFactory.createNumberSpinner (gifWaitingTimeModel, 4, new ChangeListener () {
@Override public void stateChanged (ChangeEvent ce) {
gifWaitingTime = gifWaitingTimeModel.getNumber ().intValue ();
}
}),
Multilingual.mlnText (ComponentFactory.createLabel ("seconds"), "ja", "秒"),
Box.createHorizontalGlue ()
),
ComponentFactory.createHorizontalBox (
Multilingual.mlnText (ComponentFactory.createLabel ("Recording time"), "ja", "録画時間"),
Box.createHorizontalGlue ()
),
ComponentFactory.createHorizontalBox (
Box.createHorizontalStrut (20),
ComponentFactory.createNumberSpinner (gifRecordingTimeModel, 4, new ChangeListener () {
@Override public void stateChanged (ChangeEvent ce) {
gifRecordingTime = gifRecordingTimeModel.getNumber ().intValue ();
}
}),
Multilingual.mlnText (ComponentFactory.createLabel ("seconds"), "ja", "秒"),
Box.createHorizontalGlue ()
),
ComponentFactory.createHorizontalBox (
Multilingual.mlnText (ComponentFactory.createLabel ("Magnification"), "ja", "倍率"),
Box.createHorizontalGlue ()
),
ComponentFactory.createHorizontalBox (
Box.createHorizontalStrut (20),
ComponentFactory.createNumberSpinner (gifMagnificationModel, 4, new ChangeListener () {
@Override public void stateChanged (ChangeEvent ce) {
gifMagnification = gifMagnificationModel.getNumber ().intValue ();
}
}),
ComponentFactory.createLabel ("%"),
Box.createHorizontalGlue ()
),
ComponentFactory.createHorizontalSeparator (),
Multilingual.mlnText (
ComponentFactory.createRadioButtonMenuItem (
interpolationGroup,
gifInterpolation == RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR,
"Nearest neighbor",
new ActionListener () {
@Override public void actionPerformed (ActionEvent ae) {
gifInterpolation = RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR;
}
}),
"ja", "最近傍補間"),
Multilingual.mlnText (
ComponentFactory.createRadioButtonMenuItem (
interpolationGroup,
gifInterpolation == RenderingHints.VALUE_INTERPOLATION_BILINEAR,
"Bilinear",
new ActionListener () {
@Override public void actionPerformed (ActionEvent ae) {
gifInterpolation = RenderingHints.VALUE_INTERPOLATION_BILINEAR;
}
}),
"ja", "線形補間"),
Multilingual.mlnText (
ComponentFactory.createRadioButtonMenuItem (
interpolationGroup,
gifInterpolation == RenderingHints.VALUE_INTERPOLATION_BICUBIC,
"Bicubic",
new ActionListener () {
@Override public void actionPerformed (ActionEvent ae) {
gifInterpolation = RenderingHints.VALUE_INTERPOLATION_BICUBIC;
}
}),
"ja", "三次補間")
),
"ja", "GIF アニメーション録画設定");
gifTimer = new java.util.Timer ();
}
//gifTini ()
// 後始末
public static void gifTini () {
gifTimer.cancel ();
Settings.sgsPutInt ("gifwaitingtime", gifWaitingTime);
Settings.sgsPutInt ("gifrecordingtime", gifRecordingTime);
Settings.sgsPutInt ("gifmagnification", gifMagnification);
Settings.sgsPutString ("gifinterpolation",
gifInterpolation == RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR ? "nearest" :
gifInterpolation == RenderingHints.VALUE_INTERPOLATION_BILINEAR ? "bilinear" :
gifInterpolation == RenderingHints.VALUE_INTERPOLATION_BICUBIC ? "bicubic" :
"bilinear");
}
//gifStartRecording ()
// 録画開始
public static void gifStartRecording () {
if (gifNowRecording) { //録画中
return;
}
//設定をコピーする
gifScreenWidth = XEiJ.pnlScreenWidth;
gifScreenHeight = XEiJ.pnlScreenHeight;
gifStretchWidth = XEiJ.pnlStretchWidth;
gifStretchHeight = XEiJ.pnlStretchHeight;
gifStereoscopicFactor = XEiJ.pnlStereoscopicFactor;
gifStereoscopicOn = XEiJ.pnlStereoscopicOn;
gifStereoscopicMethod = XEiJ.pnlStereoscopicMethod;
//フレームレートを求める
/*
int htotal = CRTC.crtR00HFrontEndCurr + 1;
int vtotal = CRTC.crtR04VFrontEndCurr + 1;
if (htotal <= 0 || vtotal <= 0) {
return;
}
int k = CRTC.crtHRLCurr << 3 | CRTC.crtHighResoCurr << 2 | CRTC.crtHResoCurr;
double osc = (double) CRTC.crtFreqs[CRTC.CRT_OSCS[k]];
int ratio = CRTC.CRT_DIVS[k];
double hfreq = osc / (ratio * htotal << 3);
double vfreq = hfreq / vtotal;
gifDelayTime = 100.0 / vfreq; //10ms単位
*/
gifDelayTime = Math.max (2.0, (double) CRTC.crtTotalLength * 1e-1 + (double) CRTC.crtTotalLengthMNP * 1e-10); //10ms単位、最小20ms
//フレーム数を求める
/*
gifWaitingFrames = (int) Math.floor (vfreq * (double) gifWaitingTime + 0.5);
gifRecordingFrames = (int) Math.floor (vfreq * (double) gifRecordingTime + 0.5);
*/
gifWaitingFrames = (int) Math.floor ((double) gifWaitingTime * 100.0 / gifDelayTime + 0.5);
gifRecordingFrames = (int) Math.floor ((double) gifRecordingTime * 100.0 / gifDelayTime + 0.5);
int fullSize = gifScreenWidth * gifScreenHeight * gifStereoscopicFactor;
int maxNumberOfFrames = 0x7fffffff / fullSize;
if (maxNumberOfFrames < gifRecordingFrames) {
return;
}
//バッファを確保する
int bufferSize = fullSize * gifRecordingFrames;
try {
gifBuffer = new int[bufferSize];
} catch (OutOfMemoryError oome) {
oome.printStackTrace ();
return;
}
//録画開始
gifStartRecordingMenuItem.setEnabled (false);
gifPointer = 0;
gifWaitingCounter = 0;
gifRecordingCounter = 0;
gifNowRecording = true;
CRTC.crtCaptureClock = XEiJ.mpuClockTime;
CRTC.crtFrameTaskClock = Math.min (CRTC.crtContrastClock, CRTC.crtCaptureClock);
}
//gifCaptureFrame ()
// フレームを取り込む
public static void gifCaptureFrame () {
if (gifWaitingCounter < gifWaitingFrames) {
gifWaitingCounter++;
return;
}
//ビットマップからバッファへコピーする
if (XEiJ.PNL_USE_THREAD) {
int[] bitmap = XEiJ.pnlBMLeftArray[XEiJ.pnlBMWrite & 3];
for (int y = 0; y < gifScreenHeight; y++) {
System.arraycopy (bitmap, XEiJ.PNL_BM_WIDTH * y,
gifBuffer, gifPointer,
gifScreenWidth);
gifPointer += gifScreenWidth;
}
if (gifStereoscopicFactor == 2) {
bitmap = XEiJ.pnlBMRightArray[XEiJ.pnlBMWrite & 3];
for (int y = 0; y < gifScreenHeight; y++) {
System.arraycopy (bitmap, XEiJ.PNL_BM_WIDTH * y,
gifBuffer, gifPointer,
gifScreenWidth);
gifPointer += gifScreenWidth;
}
}
} else {
for (int y = 0; y < gifScreenHeight; y++) {
System.arraycopy (XEiJ.pnlBMLeft, XEiJ.PNL_BM_WIDTH * y,
gifBuffer, gifPointer,
gifScreenWidth);
gifPointer += gifScreenWidth;
}
if (gifStereoscopicFactor == 2) {
for (int y = 0; y < gifScreenHeight; y++) {
System.arraycopy (XEiJ.pnlBMRight, XEiJ.PNL_BM_WIDTH * y,
gifBuffer, gifPointer,
gifScreenWidth);
gifPointer += gifScreenWidth;
}
}
}
gifRecordingCounter++;
if (gifRecordingCounter == gifRecordingFrames) {
//録画終了
CRTC.crtCaptureClock = XEiJ.FAR_FUTURE;
CRTC.crtFrameTaskClock = Math.min (CRTC.crtContrastClock, CRTC.crtCaptureClock);
//別スレッドで圧縮してファイルに出力する
gifTimer.schedule (new TimerTask () {
@Override public void run () {
gifOutput ();
}
}, 0L);
}
}
//gifOutput ()
// 圧縮してファイルに出力する
public static void gifOutput () {
System.out.println (Multilingual.mlnJapanese ? "画像を圧縮しています" : "Compressing images");
//サイズ
double zoomRatio = (double) gifMagnification / 100.0;
int zoomWidth = (int) Math.floor ((double) gifStretchWidth * zoomRatio + 0.5);
int zoomHeight = (int) Math.floor ((double) gifStretchHeight * zoomRatio + 0.5);
//入力画像
BufferedImage imageLeft = new BufferedImage (XEiJ.PNL_BM_WIDTH, gifScreenHeight, BufferedImage.TYPE_INT_RGB);
BufferedImage imageRight = new BufferedImage (XEiJ.PNL_BM_WIDTH, gifScreenHeight, BufferedImage.TYPE_INT_RGB);
int[] bmLeft = ((DataBufferInt) imageLeft.getRaster ().getDataBuffer ()).getData ();
int[] bmRight = ((DataBufferInt) imageRight.getRaster ().getDataBuffer ()).getData ();
//出力画像
BufferedImage image = new BufferedImage (zoomWidth * gifStereoscopicFactor, zoomHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g2 = image.createGraphics ();
g2.setRenderingHint (RenderingHints.KEY_INTERPOLATION, gifInterpolation);
//イメージライタ
ImageWriter imageWriter = ImageIO.getImageWritersBySuffix ("gif").next ();
ImageWriteParam writeParam = imageWriter.getDefaultWriteParam ();
try {
//ファイル名を決める
String dirName = "capture";
File dir = new File (dirName);
if (dir.exists () ? !dir.isDirectory () : !dir.mkdir ()) { //ディレクトリを作れない
gifBuffer = null;
gifNowRecording = false;
gifStartRecordingMenuItem.setEnabled (true);
return;
}
HashSet<String> nameSet = new HashSet<String> (); //ディレクトリにあるファイル名のセット
for (String name : dir.list ()) {
nameSet.add (name);
}
int number = 0;
String name;
do {
number++;
name = number + ".gif";
} while (!nameSet.add (name)); //セットにない番号を探す
name = dirName + "/" + name;
//出力開始
File file = new File (name);
if (file.exists ()) { //ないはず
file.delete ();
}
ImageOutputStream imageOutputStream = ImageIO.createImageOutputStream (file);
imageWriter.setOutput (imageOutputStream);
imageWriter.prepareWriteSequence (null);
//フレーム毎の処理
int halfSize = gifScreenWidth * gifScreenHeight;
int fullSize = halfSize * gifStereoscopicFactor;
double lastTime = 0.0; //前回の終了時刻(10ms単位)
double lastIntTime = 0.0; //前回の終了時刻を四捨五入した時刻(10ms単位)
for (int counter = 0; counter < gifRecordingFrames; ) {
int pointer = fullSize * counter;
//同じフレームをまとめる
int span = 1;
while (counter + span < gifRecordingFrames &&
Arrays.equals (gifBuffer, pointer, pointer + fullSize,
gifBuffer, pointer + fullSize * span, pointer + fullSize * (span + 1))) {
span++;
}
counter += span;
double time = lastTime + gifDelayTime * (double) span; //今回の終了時刻(10ms単位)
double intTime = Math.floor (time + 0.5); //今回の終了時刻を四捨五入した時刻(10ms単位)
IIOMetadata metadata = makeMetadata (imageWriter,
writeParam,
image,
/*
String.valueOf ((int) Math.floor (gifDelayTime * (double) span + 0.5)));
*/
String.valueOf ((int) (intTime - lastIntTime)));
lastTime = time;
lastIntTime = intTime;
//バッファからビットマップへコピーする
for (int y = 0; y < gifScreenHeight; y++) {
System.arraycopy (gifBuffer, pointer,
bmLeft, XEiJ.PNL_BM_WIDTH * y,
gifScreenWidth);
pointer += gifScreenWidth;
}
if (gifStereoscopicFactor == 2) {
for (int y = 0; y < gifScreenHeight; y++) {
System.arraycopy (gifBuffer, pointer,
bmRight, XEiJ.PNL_BM_WIDTH * y,
gifScreenWidth);
pointer += gifScreenWidth;
}
}
//ビットマップを使って画面を再構築する
g2.setColor (Color.black);
g2.fillRect (0, 0, zoomWidth * gifStereoscopicFactor, zoomHeight);
if (XEiJ.PNL_STEREOSCOPIC_ON && gifStereoscopicOn) { //立体視ON
if (gifStereoscopicMethod == XEiJ.PNL_NAKED_EYE_CROSSING) {
g2.drawImage (imageRight,
0, 0, zoomWidth, zoomHeight,
0, 0, gifScreenWidth, gifScreenHeight,
null);
g2.drawImage (imageLeft,
zoomWidth, 0, zoomWidth * 2, zoomHeight,
0, 0, gifScreenWidth, gifScreenHeight,
null);
} else if (gifStereoscopicMethod == XEiJ.PNL_NAKED_EYE_PARALLEL ||
gifStereoscopicMethod == XEiJ.PNL_SIDE_BY_SIDE) {
g2.drawImage (imageLeft,
0, 0, zoomWidth, zoomHeight,
0, 0, gifScreenWidth, gifScreenHeight,
null);
g2.drawImage (imageRight,
zoomWidth, 0, zoomWidth * 2, zoomHeight,
0, 0, gifScreenWidth, gifScreenHeight,
null);
} else { //gifStereoscopicMethod == XEiJ.PNL_TOP_AND_BOTTOM
g2.drawImage (imageLeft,
0, 0, zoomWidth, zoomHeight >> 1,
0, 0, gifScreenWidth, gifScreenHeight,
null);
g2.drawImage (imageRight,
0, zoomHeight >> 1, zoomWidth, zoomHeight,
0, 0, gifScreenWidth, gifScreenHeight,
null);
}
} else { //立体視OFF
g2.drawImage (imageLeft,
0, 0, zoomWidth, zoomHeight,
0, 0, gifScreenWidth, gifScreenHeight,
null);
}
//ファイルに出力する
imageWriter.writeToSequence (new IIOImage (image, null, metadata), writeParam);
}
//出力終了
imageWriter.endWriteSequence ();
imageOutputStream.close ();
System.out.println (Multilingual.mlnJapanese ? name + " を更新しました" : name + " was updated");
} catch (IOException ioe) {
ioe.printStackTrace ();
}
gifBuffer = null;
gifNowRecording = false;
gifStartRecordingMenuItem.setEnabled (true);
}
public static IIOMetadata makeMetadata (ImageWriter imageWriter, ImageWriteParam writeParam, BufferedImage image, String delayTime) {
IIOMetadata metadata = imageWriter.getDefaultImageMetadata (new ImageTypeSpecifier (image), writeParam);
String metaFormat = metadata.getNativeMetadataFormatName ();
Node root = metadata.getAsTree (metaFormat);
IIOMetadataNode gce = new IIOMetadataNode ("GraphicControlExtension");
gce.setAttribute ("delayTime", delayTime);
gce.setAttribute ("disposalMethod", "none");
gce.setAttribute ("transparentColorFlag", "FALSE");
gce.setAttribute ("transparentColorIndex", "0");
gce.setAttribute ("userInputFlag", "FALSE");
root.appendChild (gce);
try {
metadata.setFromTree (metaFormat, root);
} catch (IIOInvalidTreeException ite) {
ite.printStackTrace ();
}
return metadata;
}
}