GIFAnimation.java
     1: //========================================================================================
     2: //  GIFAnimation.java
     3: //    en:GIF animation recording
     4: //    ja:GIFアニメーション録画
     5: //  Copyright (C) 2003-2023 Makoto Kamada
     6: //
     7: //  This file is part of the XEiJ (X68000 Emulator in Java).
     8: //  You can use, modify and redistribute the XEiJ if the conditions are met.
     9: //  Read the XEiJ License for more details.
    10: //  https://stdkmd.net/xeij/
    11: //========================================================================================
    12: 
    13: package xeij;
    14: 
    15: import java.awt.*;  //Graphics2D,RenderingHints
    16: import java.awt.event.*;  //ActionEvent,ActionListener
    17: import java.awt.image.*;  //BufferedImage,DataBufferInt
    18: import java.io.*;  //File
    19: import java.util.*;  //HashSet,Timer
    20: import javax.imageio.*;  //ImageIO
    21: import javax.imageio.metadata.*;  //IIOMetadata
    22: import javax.imageio.stream.*;  //ImageOutputStream
    23: import javax.swing.*;  //JSpinner,SpinnerNumberModel
    24: import javax.swing.event.*;  //ChangeEvent,ChangeListener
    25: import org.w3c.dom.*;  //Node
    26: 
    27: public class GIFAnimation {
    28: 
    29:   //GIFアニメーション録画
    30: 
    31:   //待ち時間
    32:   //  例えばゲームの画面を録画するとき、ポーズをかけた状態で録画ボタンを押してポーズを解除してプレイを再開した後に録画を開始できる
    33: 
    34:   public static final int GIF_WAITING_TIME_MIN = 0;  //待ち時間(s)の最小値
    35:   public static final int GIF_WAITING_TIME_MAX = 30;  //待ち時間(s)の最大値
    36:   public static final int GIF_RECORDING_TIME_MIN = 1;  //録画時間(s)の最小値
    37:   public static final int GIF_RECORDING_TIME_MAX = 30;  //録画時間(s)の最大値
    38:   public static final int GIF_MAGNIFICATION_MIN = 10;  //倍率(%)の最小値
    39:   public static final int GIF_MAGNIFICATION_MAX = 200;  //倍率(%)の最大値
    40: 
    41:   public static int gifWaitingTime;  //待ち時間(s)
    42:   public static int gifRecordingTime;  //録画時間(s)
    43:   public static int gifMagnification;  //倍率(%)
    44:   public static Object gifInterpolation;  //補間アルゴリズム
    45: 
    46:   public static SpinnerNumberModel gifWaitingTimeModel;  //待ち時間(s)のスピナーモデル
    47:   public static SpinnerNumberModel gifRecordingTimeModel;  //録画時間(s)のスピナーモデル
    48:   public static SpinnerNumberModel gifMagnificationModel;  //倍率(%)のスピナーモデル
    49: 
    50:   public static JMenuItem gifStartRecordingMenuItem;  //録画開始メニューアイテム
    51:   public static JMenu gifSettingsMenu;  //GIFアニメーション録画設定メニュー
    52: 
    53:   public static java.util.Timer gifTimer;  //出力用のスレッド
    54: 
    55:   public static int gifScreenWidth;  //pnlScreenWidthのコピー
    56:   public static int gifScreenHeight;  //pnlScreenHeightのコピー
    57:   public static int gifStretchWidth;  //pnlStretchWidthのコピー
    58:   public static int gifStereoscopicFactor;  //pnlStereoscopicFactorのコピー
    59:   public static boolean gifStereoscopicOn;  //pnlStereoscopicOnのコピー
    60:   public static int gifStereoscopicMethod;  //pnlStereoscopicMethodのコピー
    61: 
    62:   public static double gifDelayTime;  //フレームの間隔(10ms単位)
    63:   public static int gifWaitingFrames;  //待ち時間のフレーム数
    64:   public static int gifRecordingFrames;  //録画時間のフレーム数
    65:   public static int[] gifBuffer;  //フレームバッファ
    66:   public static int gifPointer;  //フレームバッファのポインタ
    67:   public static int gifWaitingCounter;  //待ち時間のフレームカウンタ
    68:   public static int gifRecordingCounter;  //録画時間のフレームカウンタ
    69:   public static boolean gifNowRecording;  //true=録画中
    70: 
    71:   //gifInit ()
    72:   //  初期化
    73:   public static void gifInit () {
    74: 
    75:     gifWaitingTime = Math.max (GIF_WAITING_TIME_MIN, Math.min (GIF_WAITING_TIME_MAX, Settings.sgsGetInt ("gifwaitingtime")));
    76:     gifRecordingTime = Math.max (GIF_RECORDING_TIME_MIN, Math.min (GIF_RECORDING_TIME_MAX, Settings.sgsGetInt ("gifrecordingtime")));
    77:     gifMagnification = Math.max (GIF_MAGNIFICATION_MIN, Math.min (GIF_MAGNIFICATION_MAX, Settings.sgsGetInt ("gifmagnification")));
    78:     switch (Settings.sgsGetString ("gifinterpolation").toLowerCase ()) {
    79:     case "nearest":  //最近傍補間
    80:       gifInterpolation = RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR;
    81:       break;
    82:     case "bilinear":  //線形補間
    83:       gifInterpolation = RenderingHints.VALUE_INTERPOLATION_BILINEAR;
    84:       break;
    85:     case "bicubic":  //三次補間
    86:       gifInterpolation = RenderingHints.VALUE_INTERPOLATION_BICUBIC;
    87:       break;
    88:     default:
    89:       gifInterpolation = RenderingHints.VALUE_INTERPOLATION_BILINEAR;
    90:     }
    91: 
    92:     gifWaitingTimeModel = new SpinnerNumberModel (gifWaitingTime, GIF_WAITING_TIME_MIN, GIF_WAITING_TIME_MAX, 1);
    93:     gifRecordingTimeModel = new SpinnerNumberModel (gifRecordingTime, GIF_RECORDING_TIME_MIN, GIF_RECORDING_TIME_MAX, 1);
    94:     gifMagnificationModel = new SpinnerNumberModel (gifMagnification, GIF_MAGNIFICATION_MIN, GIF_MAGNIFICATION_MAX, 1);
    95: 
    96:     ButtonGroup interpolationGroup = new ButtonGroup ();
    97: 
    98:     gifStartRecordingMenuItem =
    99:       Multilingual.mlnText (
   100:         ComponentFactory.createMenuItem (
   101:           "Start recording",
   102:           new ActionListener () {
   103:             @Override public void actionPerformed (ActionEvent ae) {
   104:               gifStartRecording ();
   105:             }
   106:           }),
   107:         "ja", "録画開始");
   108: 
   109:     gifSettingsMenu =
   110:       Multilingual.mlnText (
   111:         ComponentFactory.createMenu (
   112:           "GIF animation recording settings",
   113:           ComponentFactory.createHorizontalBox (
   114:             Multilingual.mlnText (ComponentFactory.createLabel ("Waiting time"), "ja", "待ち時間"),
   115:             Box.createHorizontalGlue ()
   116:             ),
   117:           ComponentFactory.createHorizontalBox (
   118:             Box.createHorizontalStrut (20),
   119:             ComponentFactory.createNumberSpinner (gifWaitingTimeModel, 4, new ChangeListener () {
   120:               @Override public void stateChanged (ChangeEvent ce) {
   121:                 gifWaitingTime = gifWaitingTimeModel.getNumber ().intValue ();
   122:               }
   123:             }),
   124:             Multilingual.mlnText (ComponentFactory.createLabel ("seconds"), "ja", "秒"),
   125:             Box.createHorizontalGlue ()
   126:             ),
   127:           ComponentFactory.createHorizontalBox (
   128:             Multilingual.mlnText (ComponentFactory.createLabel ("Recording time"), "ja", "録画時間"),
   129:             Box.createHorizontalGlue ()
   130:             ),
   131:           ComponentFactory.createHorizontalBox (
   132:             Box.createHorizontalStrut (20),
   133:             ComponentFactory.createNumberSpinner (gifRecordingTimeModel, 4, new ChangeListener () {
   134:               @Override public void stateChanged (ChangeEvent ce) {
   135:                 gifRecordingTime = gifRecordingTimeModel.getNumber ().intValue ();
   136:               }
   137:             }),
   138:             Multilingual.mlnText (ComponentFactory.createLabel ("seconds"), "ja", "秒"),
   139:             Box.createHorizontalGlue ()
   140:             ),
   141:           ComponentFactory.createHorizontalBox (
   142:             Multilingual.mlnText (ComponentFactory.createLabel ("Magnification"), "ja", "倍率"),
   143:             Box.createHorizontalGlue ()
   144:             ),
   145:           ComponentFactory.createHorizontalBox (
   146:             Box.createHorizontalStrut (20),
   147:             ComponentFactory.createNumberSpinner (gifMagnificationModel, 4, new ChangeListener () {
   148:               @Override public void stateChanged (ChangeEvent ce) {
   149:                 gifMagnification = gifMagnificationModel.getNumber ().intValue ();
   150:               }
   151:             }),
   152:             ComponentFactory.createLabel ("%"),
   153:             Box.createHorizontalGlue ()
   154:             ),
   155:           ComponentFactory.createHorizontalSeparator (),
   156:           Multilingual.mlnText (
   157:             ComponentFactory.createRadioButtonMenuItem (
   158:               interpolationGroup,
   159:               gifInterpolation == RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR,
   160:               "Nearest neighbor",
   161:               new ActionListener () {
   162:                 @Override public void actionPerformed (ActionEvent ae) {
   163:                   gifInterpolation = RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR;
   164:                 }
   165:               }),
   166:             "ja", "最近傍補間"),
   167:           Multilingual.mlnText (
   168:             ComponentFactory.createRadioButtonMenuItem (
   169:               interpolationGroup,
   170:               gifInterpolation == RenderingHints.VALUE_INTERPOLATION_BILINEAR,
   171:               "Bilinear",
   172:               new ActionListener () {
   173:                 @Override public void actionPerformed (ActionEvent ae) {
   174:                   gifInterpolation = RenderingHints.VALUE_INTERPOLATION_BILINEAR;
   175:                 }
   176:               }),
   177:             "ja", "線形補間"),
   178:           Multilingual.mlnText (
   179:             ComponentFactory.createRadioButtonMenuItem (
   180:               interpolationGroup,
   181:               gifInterpolation == RenderingHints.VALUE_INTERPOLATION_BICUBIC,
   182:               "Bicubic",
   183:               new ActionListener () {
   184:                 @Override public void actionPerformed (ActionEvent ae) {
   185:                   gifInterpolation = RenderingHints.VALUE_INTERPOLATION_BICUBIC;
   186:                 }
   187:               }),
   188:             "ja", "三次補間")
   189:           ),
   190:         "ja", "GIF アニメーション録画設定");
   191: 
   192:     gifTimer = new java.util.Timer ();
   193:   }
   194: 
   195:   //gifTini ()
   196:   //  後始末
   197:   public static void gifTini () {
   198:     Settings.sgsPutInt ("gifwaitingtime", gifWaitingTime);
   199:     Settings.sgsPutInt ("gifrecordingtime", gifRecordingTime);
   200:     Settings.sgsPutInt ("gifmagnification", gifMagnification);
   201:     Settings.sgsPutString ("gifinterpolation",
   202:                            gifInterpolation == RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR ? "nearest" :
   203:                            gifInterpolation == RenderingHints.VALUE_INTERPOLATION_BILINEAR ? "bilinear" :
   204:                            gifInterpolation == RenderingHints.VALUE_INTERPOLATION_BICUBIC ? "bicubic" :
   205:                            "bilinear");
   206:   }
   207: 
   208:   //gifStartRecording ()
   209:   //  録画開始
   210:   public static void gifStartRecording () {
   211:     if (gifNowRecording) {  //録画中
   212:       return;
   213:     }
   214:     //設定をコピーする
   215:     gifScreenWidth = XEiJ.pnlScreenWidth;
   216:     gifScreenHeight = XEiJ.pnlScreenHeight;
   217:     gifStretchWidth = XEiJ.pnlStretchWidth;
   218:     gifStereoscopicFactor = XEiJ.pnlStereoscopicFactor;
   219:     gifStereoscopicOn = XEiJ.pnlStereoscopicOn;
   220:     gifStereoscopicMethod = XEiJ.pnlStereoscopicMethod;
   221:     //フレームレートを求める
   222:     int htotal = CRTC.crtR00HFrontEndCurr + 1;
   223:     int vtotal = CRTC.crtR04VFrontEndCurr + 1;
   224:     if (htotal <= 0 || vtotal <= 0) {
   225:       return;
   226:     }
   227:     int k = CRTC.crtHRLCurr << 3 | CRTC.crtHighResoCurr << 2 | CRTC.crtHResoCurr;
   228:     double osc = (double) CRTC.crtFreqs[CRTC.CRT_OSCS[k]];
   229:     int ratio = CRTC.CRT_DIVS[k];
   230:     double hfreq = osc / (ratio * htotal << 3);
   231:     double vfreq = hfreq / vtotal;
   232:     gifDelayTime = 100.0 / vfreq;  //10ms単位
   233:     //フレーム数を求める
   234:     gifWaitingFrames = (int) Math.floor (vfreq * (double) gifWaitingTime + 0.5);
   235:     gifRecordingFrames = (int) Math.floor (vfreq * (double) gifRecordingTime + 0.5);
   236:     int fullSize = gifScreenWidth * gifScreenHeight * gifStereoscopicFactor;
   237:     int maxNumberOfFrames = 0x7fffffff / fullSize;
   238:     if (maxNumberOfFrames < gifRecordingFrames) {
   239:       return;
   240:     }
   241:     //バッファを確保する
   242:     int bufferSize = fullSize * gifRecordingFrames;
   243:     try {
   244:       gifBuffer = new int[bufferSize];
   245:     } catch (OutOfMemoryError oome) {
   246:       oome.printStackTrace ();
   247:       return;
   248:     }
   249:     //録画開始
   250:     gifStartRecordingMenuItem.setEnabled (false);
   251:     gifPointer = 0;
   252:     gifWaitingCounter = 0;
   253:     gifRecordingCounter = 0;
   254:     gifNowRecording = true;
   255:     CRTC.crtCaptureClock = XEiJ.mpuClockTime;
   256:     CRTC.crtFrameTaskClock = Math.min (CRTC.crtContrastClock, CRTC.crtCaptureClock);
   257:   }
   258: 
   259:   //gifCaptureFrame ()
   260:   //  フレームを取り込む
   261:   public static void gifCaptureFrame () {
   262:     if (gifWaitingCounter < gifWaitingFrames) {
   263:       gifWaitingCounter++;
   264:       return;
   265:     }
   266:     //ビットマップからバッファへコピーする
   267:     for (int y = 0; y < gifScreenHeight; y++) {
   268:       System.arraycopy (XEiJ.pnlBMLeft, XEiJ.PNL_BM_WIDTH * y,
   269:                         gifBuffer, gifPointer,
   270:                         gifScreenWidth);
   271:       gifPointer += gifScreenWidth;
   272:     }
   273:     if (gifStereoscopicFactor == 2) {
   274:       for (int y = 0; y < gifScreenHeight; y++) {
   275:         System.arraycopy (XEiJ.pnlBMRight, XEiJ.PNL_BM_WIDTH * y,
   276:                           gifBuffer, gifPointer,
   277:                           gifScreenWidth);
   278:         gifPointer += gifScreenWidth;
   279:       }
   280:     }
   281:     gifRecordingCounter++;
   282:     if (gifRecordingCounter == gifRecordingFrames) {
   283:       //録画終了
   284:       CRTC.crtCaptureClock = XEiJ.FAR_FUTURE;
   285:       CRTC.crtFrameTaskClock = Math.min (CRTC.crtContrastClock, CRTC.crtCaptureClock);
   286:       //別スレッドで圧縮してファイルに出力する
   287:       gifTimer.schedule (new TimerTask () {
   288:         @Override public void run () {
   289:           gifOutput ();
   290:         }
   291:       }, 0L);
   292:     }
   293:   }
   294: 
   295:   //gifOutput ()
   296:   //  圧縮してファイルに出力する
   297:   public static void gifOutput () {
   298:     System.out.println (Multilingual.mlnJapanese ? "画像を圧縮しています" : "Compressing images");
   299:     //サイズ
   300:     double zoomRatio = (double) gifMagnification / 100.0;
   301:     int zoomWidth = (int) Math.floor ((double) gifStretchWidth * zoomRatio + 0.5);
   302:     int zoomHeight = (int) Math.floor ((double) gifScreenHeight * zoomRatio + 0.5);
   303:     //入力画像
   304:     BufferedImage imageLeft = new BufferedImage (XEiJ.PNL_BM_WIDTH, gifScreenHeight, BufferedImage.TYPE_INT_RGB);
   305:     BufferedImage imageRight = new BufferedImage (XEiJ.PNL_BM_WIDTH, gifScreenHeight, BufferedImage.TYPE_INT_RGB);
   306:     int[] bmLeft = ((DataBufferInt) imageLeft.getRaster ().getDataBuffer ()).getData ();
   307:     int[] bmRight = ((DataBufferInt) imageRight.getRaster ().getDataBuffer ()).getData ();
   308:     //出力画像
   309:     BufferedImage image = new BufferedImage (zoomWidth * gifStereoscopicFactor, zoomHeight, BufferedImage.TYPE_INT_RGB);
   310:     Graphics2D g2 = image.createGraphics ();
   311:     g2.setRenderingHint (RenderingHints.KEY_INTERPOLATION, gifInterpolation);
   312:     //イメージライタ
   313:     ImageWriter imageWriter = ImageIO.getImageWritersBySuffix ("gif").next ();
   314:     ImageWriteParam writeParam = imageWriter.getDefaultWriteParam ();
   315:     try {
   316:       //ファイル名を決める
   317:       String dirName = "capture";
   318:       File dir = new File (dirName);
   319:       if (dir.exists () ? !dir.isDirectory () : !dir.mkdir ()) {  //ディレクトリを作れない
   320:         gifBuffer = null;
   321:         gifNowRecording = false;
   322:         gifStartRecordingMenuItem.setEnabled (true);
   323:         return;
   324:       }
   325:       HashSet<String> nameSet = new HashSet<String> ();  //ディレクトリにあるファイル名のセット
   326:       for (String name : dir.list ()) {
   327:         nameSet.add (name);
   328:       }
   329:       int number = 0;
   330:       String name;
   331:       do {
   332:         number++;
   333:         name = number + ".gif";
   334:       } while (!nameSet.add (name));  //セットにない番号を探す
   335:       name = dirName + "/" + name;
   336:       //出力開始
   337:       File file = new File (name);
   338:       if (file.exists ()) {  //ないはず
   339:         file.delete ();
   340:       }
   341:       ImageOutputStream imageOutputStream = ImageIO.createImageOutputStream (file);
   342:       imageWriter.setOutput (imageOutputStream);
   343:       imageWriter.prepareWriteSequence (null);
   344:       //フレーム毎の処理
   345:       int halfSize = gifScreenWidth * gifScreenHeight;
   346:       int fullSize = halfSize * gifStereoscopicFactor;
   347:       for (int counter = 0; counter < gifRecordingFrames; ) {
   348:         int pointer = fullSize * counter;
   349:         //同じフレームをまとめる
   350:         int span = 1;
   351:         while (counter + span < gifRecordingFrames &&
   352:                Arrays.equals (gifBuffer, pointer, pointer + fullSize,
   353:                               gifBuffer, pointer + fullSize * span, pointer + fullSize * (span + 1))) {
   354:           span++;
   355:         }
   356:         counter += span;
   357:         IIOMetadata metadata = makeMetadata (imageWriter,
   358:                                              writeParam,
   359:                                              image,
   360:                                              String.valueOf ((int) Math.floor (gifDelayTime * (double) span + 0.5)));
   361:         //バッファからビットマップへコピーする
   362:         for (int y = 0; y < gifScreenHeight; y++) {
   363:           System.arraycopy (gifBuffer, pointer,
   364:                             bmLeft, XEiJ.PNL_BM_WIDTH * y,
   365:                             gifScreenWidth);
   366:           pointer += gifScreenWidth;
   367:         }
   368:         if (gifStereoscopicFactor == 2) {
   369:           for (int y = 0; y < gifScreenHeight; y++) {
   370:             System.arraycopy (gifBuffer, pointer,
   371:                               bmRight, XEiJ.PNL_BM_WIDTH * y,
   372:                               gifScreenWidth);
   373:             pointer += gifScreenWidth;
   374:           }
   375:         }
   376:         //ビットマップを使って画面を再構築する
   377:         g2.setColor (Color.black);
   378:         g2.fillRect (0, 0, zoomWidth * gifStereoscopicFactor, zoomHeight);
   379:         if (XEiJ.PNL_STEREOSCOPIC_ON && gifStereoscopicOn) {  //立体視ON
   380:           if (gifStereoscopicMethod == XEiJ.PNL_NAKED_EYE_CROSSING) {
   381:             g2.drawImage (imageRight,
   382:                           0, 0, zoomWidth, zoomHeight,
   383:                           0, 0, gifScreenWidth, gifScreenHeight,
   384:                           null);
   385:             g2.drawImage (imageLeft,
   386:                           zoomWidth, 0, zoomWidth * 2, zoomHeight,
   387:                           0, 0, gifScreenWidth, gifScreenHeight,
   388:                           null);
   389:           } else if (gifStereoscopicMethod == XEiJ.PNL_NAKED_EYE_PARALLEL ||
   390:                      gifStereoscopicMethod == XEiJ.PNL_SIDE_BY_SIDE) {
   391:             g2.drawImage (imageLeft,
   392:                           0, 0, zoomWidth, zoomHeight,
   393:                           0, 0, gifScreenWidth, gifScreenHeight,
   394:                           null);
   395:             g2.drawImage (imageRight,
   396:                           zoomWidth, 0, zoomWidth * 2, zoomHeight,
   397:                           0, 0, gifScreenWidth, gifScreenHeight,
   398:                           null);
   399:           } else {  //gifStereoscopicMethod == XEiJ.PNL_TOP_AND_BOTTOM
   400:             g2.drawImage (imageLeft,
   401:                           0, 0, zoomWidth, zoomHeight >> 1,
   402:                           0, 0, gifScreenWidth, gifScreenHeight,
   403:                           null);
   404:             g2.drawImage (imageRight,
   405:                           0, zoomHeight >> 1, zoomWidth, zoomHeight,
   406:                           0, 0, gifScreenWidth, gifScreenHeight,
   407:                           null);
   408:           }
   409:         } else {  //立体視OFF
   410:           g2.drawImage (imageLeft,
   411:                         0, 0, zoomWidth, zoomHeight,
   412:                         0, 0, gifScreenWidth, gifScreenHeight,
   413:                         null);
   414:         }
   415:         //ファイルに出力する
   416:         imageWriter.writeToSequence (new IIOImage (image, null, metadata), writeParam);
   417:       }
   418:       //出力終了
   419:       imageWriter.endWriteSequence ();
   420:       imageOutputStream.close ();
   421:       System.out.println (Multilingual.mlnJapanese ? name + " を更新しました" : name + " was updated");
   422:     } catch (IOException ioe) {
   423:       ioe.printStackTrace ();
   424:     }
   425:     gifBuffer = null;
   426:     gifNowRecording = false;
   427:     gifStartRecordingMenuItem.setEnabled (true);
   428:   }
   429: 
   430:   public static IIOMetadata makeMetadata (ImageWriter imageWriter, ImageWriteParam writeParam, BufferedImage image, String delayTime) {
   431:     IIOMetadata metadata = imageWriter.getDefaultImageMetadata (new ImageTypeSpecifier (image), writeParam);
   432:     String metaFormat = metadata.getNativeMetadataFormatName ();
   433:     Node root = metadata.getAsTree (metaFormat);
   434:     IIOMetadataNode gce = new IIOMetadataNode ("GraphicControlExtension");
   435:     gce.setAttribute ("delayTime", delayTime);
   436:     gce.setAttribute ("disposalMethod", "none");
   437:     gce.setAttribute ("transparentColorFlag", "FALSE");
   438:     gce.setAttribute ("transparentColorIndex", "0");
   439:     gce.setAttribute ("userInputFlag", "FALSE");
   440:     root.appendChild (gce);
   441:     try {
   442:       metadata.setFromTree (metaFormat, root);
   443:     } catch (IIOInvalidTreeException ite) {
   444:       ite.printStackTrace ();
   445:     }
   446:     return metadata;
   447:   }
   448: 
   449: }