"use strict"; /** * GifEncoder * * Authors * - Kevin Weiner (original Java version - kweiner@fmsware.com) * - Thibault Imbert (AS3 version - bytearray.org) * - Johan Nordberg (JS version - code@johan-nordberg.com) * - Eugene Ware (node.js streaming version - eugene@noblesmaurai.com) * - Antonio Román (TS version - kyradiscord@gmail.com) */ Object.defineProperty(exports, "__esModule", { value: true }); exports.GifEncoder = void 0; const stream_1 = require("stream"); const util_1 = require("util"); const ByteBuffer_1 = require("./ByteBuffer"); const LZWEncoder_1 = require("./LZWEncoder"); const NeuQuant_1 = require("./NeuQuant"); const NOP = () => { // no-op }; const GIF_HEADER = new TextEncoder().encode('GIF89a'); const NETSCAPE_HEADER = new Uint8Array([0x4e, 0x45, 0x54, 0x53, 0x43, 0x41, 0x50, 0x45, 0x32, 0x2e, 0x30]); // NETSCAPE2.0 /** * The color table size (bits - 1). */ const PALETTE_SIZE = 7; class GifEncoder { /** * Constructs the GIF encoder. * @param width An integer representing the GIF image's width, between `1` and `65536`. * @param height An integer representing the GIF image's height, between `1` and `65536`. */ constructor(width, height) { /** * The GIF image's width, between `1` and `65536`. */ Object.defineProperty(this, "width", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** * The GIF image's height, between `1` and `65536`. */ Object.defineProperty(this, "height", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** * The transparent color, `null` if no transparent color is given. */ Object.defineProperty(this, "transparent", { enumerable: true, configurable: true, writable: true, value: null }); /** * The transparent index in the color table. */ Object.defineProperty(this, "transparentIndex", { enumerable: true, configurable: true, writable: true, value: 0 }); /** * Number between `-1` and `65536`, `-1` indicating no repeat (GIF89a specification), otherwise repeating `repeat` * times with the exception of `0`, which repeats indefinitely. */ Object.defineProperty(this, "repeat", { enumerable: true, configurable: true, writable: true, value: -1 }); /** * Frame delay in hundredths of a second (1 = 10ms). */ Object.defineProperty(this, "delay", { enumerable: true, configurable: true, writable: true, value: 0 }); /** * The current frame. */ Object.defineProperty(this, "image", { enumerable: true, configurable: true, writable: true, value: null }); /** * The BGR byte array from the current frame. */ Object.defineProperty(this, "pixels", { enumerable: true, configurable: true, writable: true, value: null }); /** * The converted frame indexed to the palette. */ Object.defineProperty(this, "indexedPixels", { enumerable: true, configurable: true, writable: true, value: null }); /** * The number of bit planes. */ Object.defineProperty(this, "colorDepth", { enumerable: true, configurable: true, writable: true, value: null }); /** * The RGB palette. */ Object.defineProperty(this, "colorPalette", { enumerable: true, configurable: true, writable: true, value: null }); /** * The active palette entries. */ Object.defineProperty(this, "usedEntry", { enumerable: true, configurable: true, writable: true, value: [] }); /** * The disposal code (`-1` = determine defaults). */ Object.defineProperty(this, "disposalMode", { enumerable: true, configurable: true, writable: true, value: -1 }); /** * Whether or not this is the first frame. */ Object.defineProperty(this, "firstFrame", { enumerable: true, configurable: true, writable: true, value: true }); /** * The sample interval for the quantifier. */ Object.defineProperty(this, "sample", { enumerable: true, configurable: true, writable: true, value: 10 }); /** * Whether or not we started encoding. */ Object.defineProperty(this, "started", { enumerable: true, configurable: true, writable: true, value: false }); /** * The readable streams. */ Object.defineProperty(this, "readableStreams", { enumerable: true, configurable: true, writable: true, value: [] }); /** * The output buffer. */ Object.defineProperty(this, "byteBuffer", { enumerable: true, configurable: true, writable: true, value: new ByteBuffer_1.ByteBuffer() }); this.width = ~~width; this.height = ~~height; } createReadStream(readable) { if (!readable) { readable = new stream_1.Readable(); readable._read = NOP; } this.readableStreams.push(readable); return readable; } /** * Creates a write stream. * @param options The options for the write stream. * @returns A {@link Duplex}. * @example * ```typescript * const { GifEncoder } = require('@skyra/gifenc'); * const encoder = new GifEncoder(400, 200); * * pngStreamGenerator() // A user-defined `Readable`. * .pipe(encoder.createWriteStream({ repeat: -1, delay: 500, quality: 10 })) * .pipe(fs.createWriteStream('runningKitten.gif')); * ``` */ createWriteStream(options) { if (options) { if (options.delay !== undefined) this.setDelay(options.delay); if (options.framerate !== undefined) this.setFramerate(options.framerate); if (options.dispose !== undefined) this.setDispose(options.dispose); if (options.repeat !== undefined) this.setRepeat(options.repeat); if (options.transparent !== undefined) this.setTransparent(options.transparent); if (options.quality !== undefined) this.setQuality(options.quality); } const duplex = new stream_1.Duplex({ objectMode: true }); duplex._read = NOP; this.createReadStream(duplex); duplex._write = (data, _enc, next) => { if (!this.started) this.start(); this.addFrame(data); next(); }; const end = duplex.end.bind(duplex); // @ts-expect-error This is a Node 17 issue and it should not break using the library duplex.end = (...args) => { end(...args); this.finish(); }; return duplex; } /** * Sets the delay time between each frame, or changes it for subsequent frames (applies to the next frame added). * @param delay The delay between frames, in milliseconds. Must be a number between `655360` and `10`. */ setDelay(delay) { this.delay = Math.round(delay / 10); return this; } /** * Sets frame rate in frames per second. * @param fps The amount of frames per second, maximum is `100` frames per second. */ setFramerate(fps) { this.delay = Math.round(100 / fps); return this; } /** * Sets the GIF frame disposal code for the last added frame and any subsequent frames. * * Defaults to one of the following values: * - `0` : If `transparent` is set * - `2` : Otherwise * * @param disposalCode The disposal code. * @see {@link DisposalCode} */ setDispose(disposalCode) { if (disposalCode >= 0) this.disposalMode = disposalCode; return this; } /** * Sets the number of times the set of GIF frames should be played. * @param repeat The number of times between `-1` and `65536` to repeat the GIF, with two special cases: * - `-1` (**default**): play once * - `0`: repeat indefinitely * * @note This method has no effect after the first image was added. */ setRepeat(repeat) { this.repeat = repeat; return this; } /** * Sets the transparent color for the last added frame and any subsequent frames. Since all colors are subject to * modification in the quantization process, the color in the final palette for each frame closest to the given * color becomes the transparent color for that frame. May be set to null to indicate no transparent color. * @param color The color to be set in transparent pixels. */ setTransparent(color) { this.transparent = color; return this; } /** * Sets the quality of color quantization (conversion of images to the maximum 256 colors allowed by the GIF * specification). Lower values (`minimum` = 1) produce better colors, but slow processing significantly. `10` is * the default, and produces good color mapping at reasonable speeds. Values greater than 20 do not yield * significant improvements in speed. * @param quality A number between `1` and `30`. */ setQuality(quality) { if (quality < 1) quality = 1; this.sample = quality; return this; } /** * Adds the next GIF frame. The frame is not written immediately, but is actually deferred until the next frame is * received so that timing data can be inserted. Calling {@link GifEncoder.finish} will flush all frames. * @param imageData The image data to add into the next frame. */ addFrame(imageData) { if (util_1.types.isUint8ClampedArray(imageData)) { this.image = imageData; } else { this.image = imageData.getImageData(0, 0, this.width, this.height).data; } this.getImagePixels(); // convert to correct format if necessary this.analyzePixels(); // build color table & map pixels if (this.firstFrame) { this.writeLogicalScreenDescriptor(); // logical screen descriptor this.writePalette(); // global color table if (this.repeat >= 0) { // use NS app extension to indicate reps this.writeNetscapeExtension(); } } this.writeGraphicControlExtension(); // write graphic control extension this.writeImageDescriptor(); // image descriptor if (!this.firstFrame) this.writePalette(); // local color table this.writePixels(); // encode and write pixel data this.firstFrame = false; this.emit(); } /** * Adds final trailer to the GIF stream, if you don't call the finish method the GIF stream will not be valid. */ finish() { this.byteBuffer.writeByte(0x3b); // gif trailer this.end(); } /** * Writes the GIF file header */ start() { this.byteBuffer.writeBytes(GIF_HEADER); this.started = true; this.emit(); } end() { if (this.readableStreams.length === 0) return; this.emit(); for (const stream of this.readableStreams) { stream.push(null); } this.readableStreams = []; } emit() { if (this.readableStreams.length === 0 || this.byteBuffer.length === 0) return; const data = this.byteBuffer.toArray(); for (const stream of this.readableStreams) { stream.push(Buffer.from(data)); } this.byteBuffer.reset(); } /** * Analyzes current frame colors and creates a color map. */ analyzePixels() { const pixels = this.pixels; const pixelByteCount = pixels.length; const pixelCount = pixelByteCount / 3; this.indexedPixels = new Uint8Array(pixelCount); const quantifier = new NeuQuant_1.NeuQuant(pixels, this.sample); this.colorPalette = quantifier.getColorMap(); // Map image pixels to new palette: let k = 0; for (let j = 0; j < pixelCount; j++) { const r = pixels[k++] & 0xff; const g = pixels[k++] & 0xff; const b = pixels[k++] & 0xff; const index = quantifier.lookupRGB(r, g, b); this.usedEntry[index] = true; this.indexedPixels[j] = index; } this.pixels = null; this.colorDepth = 8; // Get closest match to transparent color if specified: if (this.transparent === null) return; this.transparentIndex = this.findClosest(this.transparent); // Ensure that pixels with full transparency in the RGBA image are using // the selected transparent color index in the indexed image. for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex++) { if (this.image[pixelIndex * 4 + 3] === 0) { this.indexedPixels[pixelIndex] = this.transparentIndex; } } } /** * Returns index of palette color closest to c. * @param color The color to compare. */ findClosest(color) { if (this.colorPalette === null) return -1; const r = (color & 0xff0000) >> 16; const g = (color & 0x00ff00) >> 8; const b = color & 0x0000ff; let minimumIndex = 0; let distanceMinimum = 256 * 256 * 256; const len = this.colorPalette.length; for (let i = 0; i < len;) { const index = i / 3; const dr = r - (this.colorPalette[i++] & 0xff); const dg = g - (this.colorPalette[i++] & 0xff); const db = b - (this.colorPalette[i++] & 0xff); const d = dr * dr + dg * dg + db * db; if (this.usedEntry[index] && d < distanceMinimum) { distanceMinimum = d; minimumIndex = index; } } return minimumIndex; } /** * Updates {@link GifEncoder.pixels} by creating an RGB-formatted {@link Uint8Array} from the RGBA-formatted data. */ getImagePixels() { const w = this.width; const h = this.height; this.pixels = new Uint8Array(w * h * 3); const data = this.image; for (let i = 0, count = 0; i < h; i++) { for (let j = 0; j < w; j++) { const b = i * w * 4 + j * 4; this.pixels[count++] = data[b]; this.pixels[count++] = data[b + 1]; this.pixels[count++] = data[b + 2]; } } } /** * Writes the GCE (Graphic Control Extension). */ writeGraphicControlExtension() { this.byteBuffer.writeByte(0x21); // extension introducer this.byteBuffer.writeByte(0xf9); // GCE label this.byteBuffer.writeByte(4); // data block size let transparency; let dispose; if (this.transparent === null) { transparency = 0; dispose = 0; // dispose = no action } else { transparency = 1; dispose = 2; // force clear if using transparent color } if (this.disposalMode >= 0) { dispose = this.disposalMode & 7; // user override } dispose <<= 2; // Write GCP's packed fields const fields = 0 | // XXX0_0000 : Reserved dispose | // 000X_XX00 : Disposal Method 0 | // 0000_00X0 : User Input Flag transparency; // 0000_000X : Transparent Color Flag this.byteBuffer.writeByte(fields); this.writeShort(this.delay); // delay x 1 / 100 sec this.byteBuffer.writeByte(this.transparentIndex); // transparent color index this.byteBuffer.writeByte(0); // block terminator } /** * Writes the ID (Image Descriptor). */ writeImageDescriptor() { this.byteBuffer.writeByte(0x2c); // Image Descriptor block identifier this.writeShort(0); // Image Left Position this.writeShort(0); // Image Top Position this.writeShort(this.width); // Image Width this.writeShort(this.height); // Image Height // Write the LCT (Local Color Table): const fields = this.firstFrame ? 0 // The first frame uses the GCT (Global Color Table) : 128 | // X000_0000 : Local Color Table Flag = 1 0 | // 0X00_0000 : Interlace Flag = 0 0 | // 00X0_0000 : Sort Flag = 0 0 | // 000X_X000 : Reserved PALETTE_SIZE; // 0000_0XXX : Size of Local Color Table this.byteBuffer.writeByte(fields); } /** * Writes the LSD (Logical Screen Descriptor) */ writeLogicalScreenDescriptor() { // logical screen size this.writeShort(this.width); this.writeShort(this.height); // Write the GCT (Global Color Table): const fields = 128 | // X000_0000 : GCT (Global Color Table) flag = 1 112 | // 0XXX_0000 : Color Resolution = 7 0 | // 0000_X000 : GCT sort flag = 0 0 | // 0000_0X00 : Reserved PALETTE_SIZE; // 0000_00XX : GCT (Global Color Table) size this.byteBuffer.writeByte(fields); this.byteBuffer.writeByte(0x000000); // background color index this.byteBuffer.writeByte(0); // pixel aspect ratio - assume 1:1 } /** * Writes the Netscape application extension to define repeat count. */ writeNetscapeExtension() { // Reference: http://www.vurdalakov.net/misc/gif/netscape-looping-application-extension this.byteBuffer.writeByte(0x21); // Extension this.byteBuffer.writeByte(0xff); // Application Extension this.byteBuffer.writeByte(0x0b); // Block Size this.byteBuffer.writeBytes(NETSCAPE_HEADER); // Application Identifier + Application Authentication Code this.byteBuffer.writeByte(0x03); // Sub-block data size this.byteBuffer.writeByte(0x01); // Sub-block ID this.writeShort(this.repeat); // Loop Count (up to 2 bytes, `0` = repeat forever) this.byteBuffer.writeByte(0); // Block Terminator } /** * Writes the color table palette. */ writePalette() { this.byteBuffer.writeBytes(this.colorPalette); this.byteBuffer.writeTimes(0, 3 * 256 - this.colorPalette.length); } writeShort(pValue) { this.byteBuffer.writeByte(pValue & 0xff); this.byteBuffer.writeByte((pValue >> 8) & 0xff); } /** * Encodes and writes pixel data into {@link GifEncoder.byteBuffer}. */ writePixels() { const enc = new LZWEncoder_1.LZWEncoder(this.width, this.height, this.indexedPixels, this.colorDepth); enc.encode(this.byteBuffer); } } exports.GifEncoder = GifEncoder; //# sourceMappingURL=GifEncoder.js.map