
org.monte.media.anim.ANIMOutputStream Maven / Gradle / Ivy
/*
* @(#)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}.
*
* Reference:
* 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.
*
* The graphics mode is an or-combination of the monitor ID and the mode ID.
*
* Example:
*
* setCAMG(PAL_MONITOR_ID|HAM_MODE);
*
*
* 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.
*
* 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 java.io.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 java.io.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).
*
*
* 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;
*
*/
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).
*
*
* 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.
*
* 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).
*
* The ops are of three classes, and followed by a varying amount of data
* depending on which class:
*
* - 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.
* - 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.
* - Same ops - this is a 0 byte followed by a count byte, followed by a
* byte value to repeat count times.
*
*
* 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.
*
* Reference:
* 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 java.io.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).
*
*
* 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;
*
*/
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();
}
}