/* * @(#)ANIMOutputStream.java 1.0 2010-12-26 * * Copyright © 2010 Werner Randelshofer, Goldau, Switzerland. * All rights reserved. * * You may not use, copy or modify this file, except in compliance with the * license agreement you entered into with Werner Randelshofer. * For details see accompanying license terms. */ package org.monte.media.anim; import java.util.Map; import java.util.HashMap; import org.monte.media.image.BitmapImage; import org.monte.media.io.SeekableByteArrayOutputStream; import org.monte.media.iff.IFFOutputStream; import java.awt.image.IndexColorModel; import java.io.File; import java.io.IOException; import java.util.Arrays; import java.util.Collections; import javax.imageio.stream.FileImageOutputStream; import static java.lang.Math.*; /** * {@code ANIMOutputStream}. * <p> * Reference:<br> * Commodore-Amiga, Inc. (1991) Amiga ROM Kernel Reference Manual. Devices. * Third Edition. Reading: Addison-Wesley. * * @author Werner Randelshofer * @version 1.0 2010-12-26 Created. */ public class ANIMOutputStream { /** CAMG monitor ID mask. */ public final static int MONITOR_ID_MASK = 0xffff1000; /** Default ID chooses a system dependent screen mode. We always fall back * to NTSC OCS with 60fps. * * The default monitor ID triggers OCS mode! * OCS stands for "Original Chip Set". The OCS chip set only had 4 bits per color register. * All later chip sets hat 8 bits per color register. */ public final static int DEFAULT_MONITOR_ID = 0x00000000; /** NTSC, 60fps, 44:52. */ public final static int NTSC_MONITOR_ID = 0x00011000; /** PAL, 50fps, 44:44. */ public final static int PAL_MONITOR_ID = 0x00021000; /** MULTISCAN (VGA), 58fps, 44:44. */ public final static int MULTISCAN_MONITOR_ID = 0x00031000; /** A2024, 60fps (I don't know the real value). */ public final static int A2024_MONITOR_ID = 0x00041000; /** PROTO, 60fps (I don't know the real value). */ public final static int PROTO_MONITOR_ID = 0x00051000; /** EURO72, 69fps, 44:44. */ public final static int EURO72_MONITOR_ID = 0x00061000; /** EURO36, 73fps, 44:44. */ public final static int EURO36_MONITOR_ID = 0x00071000; /** SUPER72, 71fps, 34:40. */ public final static int SUPER72_MONITOR_ID = 0x00081000; /** DBLNTSC, 58fps, 44:52. */ public final static int DBLNTSC_MONITOR_ID = 0x00091000; /** DBLPAL, 48fps, 44:44. */ public final static int DBLPAL_MONITOR_ID = 0x00001000; /** CAMG Mode mask. */ public final static int MODE_MASK = 0x00000880; /** CAMG HAM mode. */ public final static int HAM_MODE = 0x00000800; /** CAMG EHB mode. */ public final static int EHB_MODE = 0x00000080; /** "jiffies" defines the time base of the movie. */ private int jiffies = 60; /** Commodore Amiga graphics mode. */ private int camg; private boolean debug = false; private IFFOutputStream out = null; /** Frame count. */ protected int frameCount = 0; /** Current absolute frame time. */ protected int absTime = 0; /** double buffering previous frame for odd frames. */ private BitmapImage oddPrev; /** double buffering previous frame for even frames. */ private BitmapImage evenPrev; /** store first frame so that we can write wrap up frame. */ private BitmapImage firstFrame; /** Duration of the first wrapup frame. */ private int firstWrapupDuration = 1; /** Duration of the second wrapup frame. */ private int secondWrapupDuration = 1; /** Offset of the DPAN chunk. */ private long numberOfFramesOffset = -1; /** * The states of the movie output stream. */ private static enum States { REALIZED, STARTED, FINISHED, CLOSED; } /** * The current state of the movie output stream. */ private States state = States.REALIZED; public ANIMOutputStream(File file) throws IOException { out = new IFFOutputStream(new FileImageOutputStream(file)); } /** Sets the time base of the movie. The default value is 60. */ public void setJiffies(int newValue) { this.jiffies = newValue; } /** Gets the time base of the movie. The default value is 60. */ public int getJiffies() { return this.jiffies; } /** Sets the Commodore Amiga Graphics Mode. The default value is 0. * <p> * The graphics mode is an or-combination of the monitor ID and the mode ID. * <p> * Example: * <pre> * setCAMG(PAL_MONITOR_ID|HAM_MODE); * </pre> * * Also sets the Jiffies for the Graphics Mode. */ public void setCAMG(int newValue) { this.camg = newValue; AmigaDisplayInfo info=AmigaDisplayInfo.getInfo(newValue); if (info!=null)this.jiffies=info.fps; } /** Gets the Commodore Amiga Graphics Mode. The default value is 0. */ public int getCAMG() { return this.camg; } /** * Check to make sure that this stream has not been closed */ private void ensureOpen() throws IOException { if (state == States.CLOSED) { throw new IOException("Stream closed"); } } /** * Sets the state of the QuickTimeWriter to started. * <p> * If the state is changed by this method, the prolog is * written. */ private void ensureStarted() throws IOException { ensureOpen(); if (state == States.FINISHED) { throw new IOException("Can not write into finished movie."); } if (state != States.STARTED) { writeProlog(); state = States.STARTED; } } /** * Finishes writing the contents of the QuickTime output stream without closing * the underlying stream. Use this method when applying multiple filters * in succession to the same output stream. * * @exception IllegalStateException if the dimension of the video track * has not been specified or determined yet. * @exception IOException if an I/O exception has occurred */ public void finish() throws IOException { ensureOpen(); if (state != States.FINISHED) { writeEpilog(); out.finish(); state = States.FINISHED; } } /** * Closes the movie file as well as the stream being filtered. * * @exception IOException if an I/O error has occurred */ public void close() throws IOException { try { if (state == States.STARTED) { finish(); } } finally { if (state != States.CLOSED) { out.close(); state = States.CLOSED; } } } private void writeProlog() throws IOException { out.pushCompositeChunk("FORM", "ANIM"); } private void writeEpilog() throws IOException { if (frameCount > 0) { // Write wrapup frames writeDeltaFrame(firstFrame, firstWrapupDuration); writeDeltaFrame(firstFrame, secondWrapupDuration); } out.popChunk(); if (numberOfFramesOffset!=-1) { long pos=out.getStreamPosition(); out.seek(numberOfFramesOffset); out.writeUWORD(max(0,frameCount-2)); out.seek(pos); } } public void writeFrame(BitmapImage image, int duration) throws IOException { ensureStarted(); if (frameCount == 0) { writeFirstFrame(image, duration); } else { writeDeltaFrame(image, duration); } } private void writeFirstFrame(BitmapImage img, int duration) throws IOException { out.pushCompositeChunk("FORM", "ILBM"); writeBMHD(out, img); writeCMAP(out, img); // writeDPAN(out); writeANHD(out, img.getWidth(), img.getHeight(), 0, absTime, duration); // 0=opDirect writeCAMG(out, camg); writeBODY(out, img); out.popChunk(); firstFrame = new BitmapImage(img.getWidth(), img.getHeight(), img.getDepth(), img.getPlanarColorModel()); oddPrev = new BitmapImage(img.getWidth(), img.getHeight(), img.getDepth(), img.getPlanarColorModel()); evenPrev = new BitmapImage(img.getWidth(), img.getHeight(), img.getDepth(), img.getPlanarColorModel()); System.arraycopy(img.getBitmap(), 0, firstFrame.getBitmap(), 0, img.getBitmap().length); System.arraycopy(img.getBitmap(), 0, oddPrev.getBitmap(), 0, img.getBitmap().length); System.arraycopy(img.getBitmap(), 0, evenPrev.getBitmap(), 0, img.getBitmap().length); absTime += duration; firstWrapupDuration = secondWrapupDuration = duration; frameCount++; } private void writeDeltaFrame(BitmapImage img, int duration) throws IOException { BitmapImage prev = (frameCount & 1) == 0 ? evenPrev : oddPrev; // double buffered previous BitmapImage immPrev = (frameCount & 1) == 0 ? oddPrev : evenPrev; // immediate previous out.pushCompositeChunk("FORM", "ILBM"); writeANHD(out, img.getWidth(), img.getHeight(), 0x5, absTime, duration); // 0x5=byteVerticalDeltaMode writeCMAP(out, img, immPrev); writeDLTA(out, img, prev); out.popChunk(); System.arraycopy(img.getBitmap(), 0, prev.getBitmap(), 0, prev.getBitmap().length); prev.setPlanarColorModel(img.getPlanarColorModel()); absTime += duration; firstWrapupDuration = secondWrapupDuration = duration; frameCount++; } public long getMovieTime() {return absTime;} /** * Writes the bitmap header (ILBM BMHD). * * <pre> * typedef UBYTE Masking; // Choice of masking technique * * #define mskNone 0 * #define mskHasMask 1 * #define mskHasTransparentColor 2 * #define mskLasso 3 * * typedef UBYTE Compression; // Choice of compression algorithm * // applied to the rows of all source and mask planes. * // "cmpByteRun1" is the byte run encoding. Do not compress * // accross rows! * #define cmpNone 0 * #define cmpByteRun1 1 * * typedef struct { * UWORD w, h; // raster width & height in pixels * WORD x, y; // pixel position for this image * UBYTE nbPlanes; // # source bitplanes * Masking masking; * Compression compression; * UBYTE pad1; // unused; ignore on read, write as 0 * UWORD transparentColor; // transparent "color number" (sort of) * UBYTE xAspect, yAspect; // pixel aspect, a ratio width : height * UWORD pageWidth, pageHeight; // source "page" size in pixels * } BitmapHeader; * </pre> */ private void writeBMHD(IFFOutputStream out, BitmapImage img) throws IOException { AmigaDisplayInfo info=AmigaDisplayInfo.getInfo(camg); if (info==null)info=AmigaDisplayInfo.getInfo(AmigaDisplayInfo.DEFAULT_MONITOR_ID); out.pushDataChunk("BMHD"); out.writeUWORD(img.getWidth()); out.writeUWORD(img.getHeight()); out.writeWORD(0); out.writeWORD(0); out.writeUBYTE(img.getDepth()); out.writeUBYTE(0); // mskNone out.writeUBYTE(1); // cmpByteRun1 out.writeUBYTE(0); out.writeUWORD(0); out.writeUBYTE(info.resolutionX); out.writeUBYTE(info.resolutionY); out.writeUWORD(img.getWidth()); out.writeUWORD(img.getHeight()); out.popChunk(); } /** * Writes the color map (ILBM CMAP). */ private void writeCMAP(IFFOutputStream out, BitmapImage img) throws IOException { out.pushDataChunk("CMAP"); IndexColorModel cm = (IndexColorModel) img.getPlanarColorModel(); for (int i = 0, n = cm.getMapSize(); i < n; ++i) { out.writeUBYTE(cm.getRed(i)); out.writeUBYTE(cm.getGreen(i)); out.writeUBYTE(cm.getBlue(i)); } out.popChunk(); } /** * Writes the color map (ILBM CMAP) if it is different from the previous image. */ private void writeCMAP(IFFOutputStream out, BitmapImage img, BitmapImage prev) throws IOException { IndexColorModel cm = (IndexColorModel) img.getPlanarColorModel(); IndexColorModel prevCm = (IndexColorModel) prev.getPlanarColorModel(); boolean equals = true; for (int i = 0, n = cm.getMapSize(); i < n; ++i) { if (cm.getRGB(i) != prevCm.getRGB(i)) { equals = false; break; } } if (!equals) { writeCMAP(out, img); } } /** * Writes the color Amiga viewport mode display id (ILBM CAMG). */ private void writeCAMG(IFFOutputStream out, int camg) throws IOException { out.pushDataChunk("CAMG"); out.writeLONG(camg); out.popChunk(); } /** * Writes the DPAN (Deluxe Paint Animation) chunk. * We can fill in the value for numberOfFrames only after we have written * all frames. * * typedef struct { * UWORD version; // current version=4 * UWORD numberOfFrames; // number of frames in the animation. * ULONG flags; // Not used * } * animDPAnimChunk; * */ private void writeDPAN(IFFOutputStream out) throws IOException { out.pushDataChunk("DPAN"); out.writeUWORD(4); numberOfFramesOffset = out.getStreamPosition(); out.writeUWORD(-1); // we don't know the number of frames yet out.writeULONG(0); out.popChunk(); } /** * Writes the body (ILBM BODY). */ private void writeBODY(IFFOutputStream out, BitmapImage img) throws IOException { out.pushDataChunk("BODY"); int widthInBytes = (img.getWidth() + 7) / 8; int ss = img.getScanlineStride(); int bs = img.getBitplaneStride(); int offset = 0; byte[] data = img.getBitmap(); for (int y = 0, h = img.getHeight(); y < h; y++) { for (int p = 0, d = img.getDepth(); p < d; p++) { out.writeByteRun1(data, offset + bs * p, widthInBytes); } offset += ss; } out.popChunk(); } /** * Writes a delta frame (ILBM DLTA) with "byte vertical" (method 5). * * <p> * The DLTA chunk for method 5 has 16 long pointers at the start. * The first 8 are pointers to the start of the data for each of the * bitplanes (up to a max of 8 planes). The second set of 8 are not used. * <p> * Compression/decompression is performed on a plane-by-plane basis. * Each column is compressed separately. A 320x200 bitplane would have 40 * columns of 200 bytes each. Each column starts with an op-count followed * by a number of ops. If the op-count is zero, that's ok, it just means * there's no change in this column from the last frame. If there is only * one list of pointers ops for all planes, then the pointer to that list * is repeated in all positions so the playback code need not even be * aware of it.In fact, one could get fancy and have some bitplanes share * lists while others have different lists, or no lists (the problem in * these schemes lie in the generation, not in the playback). * <p> * The ops are of three classes, and followed by a varying amount of data * depending on which class: * <ol> * <li>Skip ops - this is a byte whith the hi bit clear that says how * many rows to move the "dest" pointer forward, ie to skip. It is non-zero.</li> * <li>Uniq ops - this is a byte with the hi bit set. The hi bit is masked * down and the remainder is a count of the number of bytes of data to copy * literally. It's followed by the data to copy.</li> * <li>Same ops - this is a 0 byte followed by a count byte, followed by a * byte value to repeat count times.</li> * </ol> * <p> * Do bear in mind that the data is compressed vertically rather than * horizontally, so to get to the next byte in the destination we add the * number of bytes per row instead of one. * <p> * Reference:<br> * Commodore-Amiga, Inc. (1991) Amiga ROM Kernel Reference Manual. Devices. * Third Edition. Reading: Addison-Wesley. * Pages 445 - 449. */ private void writeDLTA(IFFOutputStream out, BitmapImage img, BitmapImage prev) throws IOException { out.pushDataChunk("DLTA"); int height = img.getHeight(); int widthInBytes = (img.getWidth() + 7) / 8; int ss = img.getScanlineStride(); int bs = img.getBitplaneStride(); int offset = 0; byte[] data = img.getBitmap(); byte[] prevData = prev.getBitmap(); SeekableByteArrayOutputStream buf = new SeekableByteArrayOutputStream(); // Buffers for a theoretical maximum of 16 planes. byte[][] planes = new byte[16][0]; // Repeat for each plane. int depth = img.getDepth(); for (int p = 0; p < depth; ++p) { buf.reset(); // Each column of the plane is compressed separately. for (int column = 0; column < widthInBytes; ++column) { writeByteVertical(buf, data, prevData, bs * p + column, height, ss); } planes[p] = buf.toByteArray(); if (planes[p].length == widthInBytes) { // => all columns have an op-count of 0. We can drop them entirely. planes[p] = new byte[0]; } } // pPointers are the pointers (index) to the op-codes of each plane. int[] pPointers = new int[16]; // Compute pointers. If two planes have the same delta, we only store // the delta once. for (int p = 0; p < depth; ++p) { if (planes[p].length == 0) { pPointers[p] = 0; } else { pPointers[p] = 16 * 4; for (int q = 0; q < p; ++q) { if (Arrays.equals(planes[q], planes[p])) { pPointers[p] = pPointers[q]; planes[p] = new byte[0]; break; } pPointers[p] += planes[q].length; } } } // write pointers for each bitmap plane for (int p = 0; p < pPointers.length; ++p) { out.writeULONG(pPointers[p]); } // write deltas for (int p = 0; p < planes.length; ++p) { out.write(planes[p]); } out.popChunk(); } /** * Encodes a column of an image with the "byte vertical" method (method 5). * * @param out * @param data * @param prev * @param offset * @param length * @param step * @throws IOException */ private void writeByteVertical(SeekableByteArrayOutputStream out, byte[] data, byte[] prev, int offset, int length, int step) throws IOException { int opCount = 0; // Reserve space for opCount in the stream long opCountPos = out.getStreamPosition(); out.write(0); // Start offset of the literal run int literalOffset = 0; int i; for (i = 0; i < length; i++) { // Count skips int skipCount = i; for (; skipCount < length; skipCount++) { if (data[offset + skipCount * step] != prev[offset + skipCount * step]) { break; } } skipCount = skipCount - i; // Can we skip until the end? if (skipCount + i == length) { break; } if (skipCount > 0 && literalOffset == i || skipCount > 1) { // Flush the literal run, if we have one if (literalOffset < i) { opCount++; out.write(0x80 | (i - literalOffset)); // Write Uniq Op for (int j = literalOffset; j < i; j++) { out.write(data[offset + j * step]); } } // Write the skip count i += skipCount - 1; literalOffset = i + 1; for (; skipCount > 127; skipCount -= 127) { opCount++; out.write(127); // Write Skip Op } opCount++; out.write(skipCount); // Write Skip Op } else { // Read a byte byte b = data[offset + i * step]; // Count repeats of that byte int repeatCount = i + 1; for (; repeatCount < length; repeatCount++) { if (data[offset + repeatCount * step] != b) { break; } } repeatCount = repeatCount - i; if (repeatCount == 1) { // Flush the literal run, if it gets too large if (i - literalOffset > 126) { opCount++; out.write(0x80 | (i - literalOffset)); // Write Uniq Op for (int j = literalOffset; j < i; j++) { out.write(data[offset + j * step]); } literalOffset = i; } // If the byte repeats less than 4 times, and we have a literal // run with enough space, add it to the literal run } else if (repeatCount < 4 && literalOffset < i && i - literalOffset < 126) { i++; } else { // Flush the literal run, if we have one if (literalOffset < i) { opCount++; out.write(0x80 | (i - literalOffset)); // Write Uniq Op for (int j = literalOffset; j < i; j++) { out.write(data[offset + j * step]); } } // Write the repeat run i += repeatCount - 1; literalOffset = i + 1; // We have to write multiple runs, if the byte repeats more // than 256 times. for (; repeatCount > 255; repeatCount -= 255) { opCount++; out.write(0); out.write(255); // Write Same Op out.write(b); } opCount++; out.write(0); out.write(repeatCount); // Write Same Op out.write(b); } } } // Flush the literal run, if we have one if (literalOffset < i) { opCount++; out.write(0x80 | (i - literalOffset)); // Write Uniq Op for (int j = literalOffset; j < i; j++) { out.write(data[offset + j * step]); } } // Write the opCount long pos = out.getStreamPosition(); out.seek(opCountPos); out.write(opCount); out.seek(pos); } /** * Writes the anim header (ILBM ANHD). * * <pre> * typedef UBYTE Operation; // Choice of compression algorithm. * * #define opDirect 0 // set directly (normal ILBM BODY) * #define opXOR 1 // XOR ILBM mode * #define opLongDelta 2 // Long Delta mode * #define opShortDelta 3 // Short Delta Mode * #define opGeneralDelta 4 // Generalized short/long Delta mode * #define opByteVertical 5 // Byte Vertical Delta mode * #define opStereoDelta 6 // Stereo op 5 (third party) * #define opVertical7 7 // Short/Long Vertical Delta mode (opcodes and data stored separately) * #define opVertical8 8 // Short/Long Vertical Delta mode (opcodes and data combined) * #define opJ 74 // (ascii 'J') reserved for Eric Graham's compression technique * * typedef struct { * Operation operation; // The compression method. * UBYTE mask; // XOR mode only - plane mask where each * // bit is set =1 if there is data and =0 * // if not. * UWORD w,h; // XOR mode only - width and height of the * // area represented by the BODY to eliminate * // unnecessary un-changed data. * UWORD x,y; // XOR mode only - position of rectangular * // area represented by the BODY. * ULONG abstime; // currently unused - timing for a frame * // relative to the time the first frame * // was displayed - in jiffies (1/60 sec). * ULONG reltime; // timing for frame relative to time * // previous frame was displayed - in * // jiffies (1/60 sec). * UBYTE interleave;// unused so far - indicates how many frames * // back this data is to modify. =0 defaults * // to indicate two frames back (for double * // buffering). =n indicates n frames back. * // The main intent here is to allow values * // of =1 for special applications where * // frame data would modify the immediately * // previous frame. * UBYTE pad0; // Pad byte, not used at present. * ULONG bits; // 32 option bits used by opGeneralDelta, * // opByteVertical, opVertical7 and opVertical8. * // At present only 6 are identified, but the * // rest are set =0 so they can be used to * // implement future ideas. These are defined * // for opGeneralData only at this point. It is * // recommended that all bits be set =0 for * // opByteVertical and that any bit settings used in * // the future (such as for XOR mode) be compatible * // with the opGeneralData settings. Player code * // should check undefined bits in opGeneralData and * // opByteVertical to assure they are zero. * // * // The six bits for current use are: * // * // bit # set =0 set =1 * // ======================================= * // 0 short data long data * // 1 set XOR * // 2 separate info one info list * // for each plane for all planes * // 3 not RLC RLC (run length coded) * // 4 horizontal vertical * // 5 short info offsets long info offsets * * UBYTE pad[16]; // This is a pad for future use for future * // compression modes. * } AnimHeader; * </pre> */ private void writeANHD(IFFOutputStream out, int width, int height, int compressionMode, int absTime, int relTime) throws IOException { out.pushDataChunk("ANHD"); out.writeUBYTE(compressionMode); out.writeUBYTE(0); out.writeUWORD(width); out.writeUWORD(height); out.writeUWORD(0); out.writeUWORD(0); out.writeULONG(absTime); out.writeULONG(relTime); out.writeUBYTE(0); out.writeUBYTE(0); out.writeULONG(0); // bits out.writeULONG(0); // pad out.writeULONG(0); out.writeULONG(0); out.writeULONG(0); out.popChunk(); } }