/** * WTVImage - WebTV PNG/Image Conversion Utility * * WebTV cannot display PNG natively. This class converts PNGs (and other * sharp-compatible sources) to the appropriate WebTV format: * * • No alpha channel → JPEG (WebTV supports JPEG natively) * • Palette/indexed PNG (color type 3) with alpha → Artemis ALF GIF 1:1 * (palette and per-palette alpha values preserved exactly from PLTE/tRNS) * • Full-color RGBA PNG → quantized Artemis ALF GIF * * Artemis ALP/ALF format: * WebTV's "Artemis" format embeds a GIF Application Extension block with the * identifier "Artemis ALP" or "Artemis ALF" containing a per-palette-entry * alpha lookup table. This breaks the GIF89a standard but was supported by * WebTV's own rendering engine. * * ALP – the alpha table is prefixed with a black (0,0,0) phantom entry so * that palette index 0 is always fully-transparent black. * ALF – the alpha table is suffixed with a black (0,0,0) phantom entry * (last palette slot is fully-transparent black). * * Decoder reverse-engineered from: * https://gist.github.com/PajamaFrix/399c0785c5bb3b1d80757e84a0c1d6ab */ const sharp = require('sharp'); const zlib = require('zlib'); // --------------------------------------------------------------------------- // Class wrapper // --------------------------------------------------------------------------- class WTVImage { // --------------------------------------------------------------------------- // Low-level GIF 89a helpers // --------------------------------------------------------------------------- /** Parse the raw GIF logical screen descriptor and return key fields. */ parseGIFHeader(buf) { if (buf.length < 13) throw new Error('Buffer too short to be a GIF'); const sig = buf.slice(0, 6).toString('ascii'); if (sig !== 'GIF87a' && sig !== 'GIF89a') throw new Error('Not a GIF file'); const width = buf.readUInt16LE(6); const height = buf.readUInt16LE(8); const packed = buf[10]; const hasGCT = (packed & 0x80) !== 0; const gctSize = hasGCT ? (1 << ((packed & 0x07) + 1)) : 0; // number of entries const bgIndex = buf[11]; const pixelAR = buf[12]; const gctOffset = 13; const gctBytes = gctSize * 3; return { width, height, packed, hasGCT, gctSize, gctOffset, gctBytes, bgIndex, pixelAR }; } /** * Write a GIF89a header + logical screen descriptor. * @param {number} width * @param {number} height * @param {number} gctEntries - number of palette entries (must be power of 2, 2–256) * @param {number} bgIndex * @returns {Buffer} 13-byte header */ buildGIFHeader(width, height, gctEntries, bgIndex = 0) { const sizeField = Math.log2(gctEntries) - 1; // 0-7 const packed = 0x80 | 0x70 | (sizeField & 0x07); // GCT present, color resolution=8 bits, no sort, gctSize const buf = Buffer.alloc(13); buf.write('GIF89a', 0, 'ascii'); buf.writeUInt16LE(width, 6); buf.writeUInt16LE(height, 8); buf[10] = packed; buf[11] = bgIndex; buf[12] = 0x00; // pixel aspect ratio return buf; } /** * Build a GIF89a Graphics Control Extension block. * Reference WebTV Artemis GIFs always have a GCE with transparentFlag=1 * and transparentColorIndex=0, used by the renderer for hard transparency. * The Artemis alpha table provides additional partial alpha for the rest. * @param {number} transparentIdx - palette index treated as fully transparent * @param {boolean} hasTransparent - whether to set the transparent flag * @returns {Buffer} 8-byte GCE block */ buildGCE(transparentIdx = 0, hasTransparent = true) { const buf = Buffer.alloc(8); buf[0] = 0x21; // extension introducer buf[1] = 0xF9; // graphic control label buf[2] = 0x04; // block size buf[3] = hasTransparent ? 0x01 : 0x00; // packed: transparent color flag buf[4] = 0x00; buf[5] = 0x00; // delay time buf[6] = transparentIdx & 0xFF; buf[7] = 0x00; // block terminator return buf; } /** * Build a GIF Application Extension block. * @param {string} appName - exactly 8 chars * @param {string} authCode - exactly 3 chars * @param {Buffer} data * @returns {Buffer} */ buildAppExtension(appName, authCode, data) { if (appName.length !== 8) throw new Error('GIF app name must be 8 chars'); if (authCode.length !== 3) throw new Error('GIF auth code must be 3 chars'); // Split data into sub-blocks (max 255 bytes each) const subBlocks = []; let offset = 0; while (offset < data.length) { const len = Math.min(255, data.length - offset); subBlocks.push(Buffer.from([len])); subBlocks.push(data.slice(offset, offset + len)); offset += len; } subBlocks.push(Buffer.from([0x00])); // block terminator return Buffer.concat([ Buffer.from([0x21, 0xFF, 0x0B]), // ext introducer, app label, block size Buffer.from(appName + authCode, 'ascii'), // 11-byte app identifier ...subBlocks ]); } /** * Parse all GIF Application Extension blocks from a buffer. * Returns an array of { appName, authCode, dataOffset, dataLength, blockStart, blockEnd } */ findAppExtensions(buf) { const results = []; let i = 13; // skip header + LSD const hdr = this.parseGIFHeader(buf); i += hdr.gctBytes; // skip global color table while (i < buf.length - 1) { if (buf[i] === 0x3B) break; // GIF trailer if (buf[i] === 0x2C) break; // image descriptor – stop scanning extensions if (buf[i] === 0x21) { const label = buf[i + 1]; if (label === 0xFF) { // Application extension const blockSize = buf[i + 2]; // should be 11 if (blockSize === 0x0B && i + 2 + blockSize < buf.length) { const appId = buf.slice(i + 3, i + 14).toString('ascii'); const appName = appId.slice(0, 8); const authCode = appId.slice(8, 11); const blockStart = i; // Collect sub-block data const dataChunks = []; let j = i + 14; while (j < buf.length && buf[j] !== 0x00) { const subLen = buf[j]; if (subLen === 0) break; dataChunks.push(buf.slice(j + 1, j + 1 + subLen)); j += 1 + subLen; } const blockEnd = j + 1; // include terminator results.push({ appName, authCode, data: Buffer.concat(dataChunks), blockStart, blockEnd }); i = blockEnd; continue; } } else { // Other extension – skip sub-blocks let j = i + 2; while (j < buf.length) { const subLen = buf[j]; j++; if (subLen === 0) break; j += subLen; } i = j; continue; } } i++; } return results; } // --------------------------------------------------------------------------- // LZW encoder / decoder (minimal, for GIF image data blocks) // --------------------------------------------------------------------------- /** * Decode LZW-compressed GIF image data into an array of palette indices. * @param {Buffer} data - raw sub-block data (already concatenated) * @param {number} minCodeSize * @param {number} pixelCount * @returns {Uint8Array} */ lzwDecode(data, minCodeSize, pixelCount) { const clearCode = 1 << minCodeSize; const eodCode = clearCode + 1; let codeSize = minCodeSize + 1; let codeMask = (1 << codeSize) - 1; // Build initial code table const initTable = () => { const t = []; for (let i = 0; i < clearCode; i++) t.push([i]); t.push(null); // clear t.push(null); // eod return t; }; let table = initTable(); let nextCode = eodCode + 1; const output = new Uint8Array(pixelCount); let outIdx = 0; let bitBuf = 0; let bitCount = 0; let byteIdx = 0; const readCode = () => { while (bitCount < codeSize) { if (byteIdx >= data.length) return eodCode; bitBuf |= data[byteIdx++] << bitCount; bitCount += 8; } const code = bitBuf & codeMask; bitBuf >>= codeSize; bitCount -= codeSize; return code; }; let prevEntry = null; let code = readCode(); while (code !== eodCode) { if (code === clearCode) { table = initTable(); nextCode = eodCode + 1; codeSize = minCodeSize + 1; codeMask = (1 << codeSize) - 1; prevEntry = null; code = readCode(); if (code === eodCode) break; const entry = table[code]; for (const v of entry) output[outIdx++] = v; prevEntry = entry; } else { let entry; if (code < table.length && table[code] !== null) { entry = table[code]; } else if (code === nextCode) { entry = prevEntry.concat(prevEntry[0]); } else { break; // corrupt } for (const v of entry) output[outIdx++] = v; if (prevEntry !== null && nextCode < 4096) { table[nextCode++] = prevEntry.concat(entry[0]); if (nextCode > codeMask && codeSize < 12) { codeSize++; codeMask = (1 << codeSize) - 1; } } prevEntry = entry; } code = readCode(); } return output; } /** * Encode an array of palette indices using GIF LZW. * @param {Uint8Array} indices * @param {number} minCodeSize * @returns {Buffer} raw LZW data (not yet wrapped in sub-blocks) */ lzwEncode(indices, minCodeSize) { const clearCode = 1 << minCodeSize; const eodCode = clearCode + 1; const initTable = () => { const t = new Map(); for (let i = 0; i < clearCode; i++) t.set(String(i), i); return t; }; let table = initTable(); let nextCode = eodCode + 1; let codeSize = minCodeSize + 1; const output = []; let bitBuf = 0; let bitCount = 0; const emitCode = (code) => { bitBuf |= code << bitCount; bitCount += codeSize; while (bitCount >= 8) { output.push(bitBuf & 0xFF); bitBuf >>= 8; bitCount -= 8; } }; emitCode(clearCode); let buffer = String(indices[0]); for (let i = 1; i < indices.length; i++) { const next = buffer + ',' + indices[i]; if (table.has(next)) { buffer = next; } else { emitCode(table.get(buffer)); if (nextCode < 4096) { table.set(next, nextCode++); // GIF LZW off-by-one: decoder lags by one dict entry, so the // encoder must bump codeSize when nextCode > (1 << codeSize), // i.e., one iteration LATER than naive `>=` would suggest. if (nextCode > (1 << codeSize) && codeSize < 12) codeSize++; } else { emitCode(clearCode); table = initTable(); nextCode = eodCode + 1; codeSize = minCodeSize + 1; } buffer = String(indices[i]); } } emitCode(table.get(buffer)); emitCode(eodCode); if (bitCount > 0) output.push(bitBuf & 0xFF); return Buffer.from(output); } /** Wrap raw data into GIF sub-blocks (255-byte max each). */ wrapSubBlocks(data) { const chunks = []; let offset = 0; while (offset < data.length) { const len = Math.min(255, data.length - offset); chunks.push(Buffer.from([len])); chunks.push(data.slice(offset, offset + len)); offset += len; } chunks.push(Buffer.from([0x00])); // block terminator return Buffer.concat(chunks); } /** Read and concatenate GIF sub-block data starting at offset, return { data, endOffset }. */ readSubBlocks(buf, offset) { const chunks = []; while (offset < buf.length) { const len = buf[offset++]; if (len === 0) break; chunks.push(buf.slice(offset, offset + len)); offset += len; } return { data: Buffer.concat(chunks), endOffset: offset }; } /** * Build the Artemis alpha lookup table payload for ALP/ALF. * * ALP stores its alpha table with a phantom transparent palette entry * at index 0, so the payload omits that slot and encodes only the * remaining indices. * ALF stores its alpha table directly and may be left full-length. */ buildArtemisAlphaTable(type, alphaTable) { if (type !== 'ALP') return Buffer.from(alphaTable); // Drop the phantom transparent index 0. // ALP output should preserve the full remaining alpha table so WebTV // does not default missing ALP entries to transparent. return Buffer.from(alphaTable.slice(1)); } isSimplePaletteTransparency(alphaTable) { let transparentIndex = -1; for (let i = 0; i < alphaTable.length; i++) { const a = alphaTable[i]; if (a !== 0 && a !== 0xFF) return false; if (a === 0) { if (transparentIndex !== -1) return false; transparentIndex = i; } } return transparentIndex >= 0; } encodePalettePNGAsStandardGIF(pngInfo) { const { palette, indices, width, height, colors, alphaTable } = pngInfo; const transparentIdx = alphaTable.findIndex((a) => a === 0); if (transparentIdx < 0) throw new Error('No transparent palette entry found'); const finalIndices = Buffer.from(indices); const finalPalette = Buffer.from(palette); const minCodeSize = Math.max(2, Math.ceil(Math.log2(colors))); const lzwEncoded = this.lzwEncode(finalIndices, minCodeSize); const lzwBlocks = this.wrapSubBlocks(lzwEncoded); const imgDesc = Buffer.alloc(10); imgDesc[0] = 0x2C; imgDesc.writeUInt16LE(0, 1); imgDesc.writeUInt16LE(0, 3); imgDesc.writeUInt16LE(width, 5); imgDesc.writeUInt16LE(height, 7); imgDesc[9] = 0x00; const gceBlock = this.buildGCE(transparentIdx, true); const gifHeader = this.buildGIFHeader(width, height, colors, 0); return Buffer.concat([ gifHeader, finalPalette, gceBlock, imgDesc, Buffer.from([minCodeSize]), lzwBlocks, Buffer.from([0x3B]) ]); } // --------------------------------------------------------------------------- // Artemis alpha extension codec // --------------------------------------------------------------------------- /** * Detect whether a GIF buffer contains an Artemis ALP or ALF block. * @param {Buffer} gifBuf * @returns {'ALP'|'ALF'|null} */ detectArtemisType(gifBuf) { if (gifBuf.indexOf(Buffer.from('Artemis ALP', 'ascii')) !== -1) return 'ALP'; if (gifBuf.indexOf(Buffer.from('Artemis ALF', 'ascii')) !== -1) return 'ALF'; return null; } /** * Decode a WebTV Artemis ALP/ALF GIF and return an RGBA Buffer (raw pixel data) * along with metadata. * * The decoder replicates the logic of artemis_alpha_splitter.py: * 1. Locate the Artemis identifier and read the alpha lookup table. * 2. Reconstruct a secondary GIF where the alpha table forms the grayscale palette. * 3. Combine the original GIF's RGB pixels with the alpha channel derived from * the reconstructed GIF. * * @param {Buffer} gifBuf - raw GIF file contents * @returns {Promise<{ rgba: Buffer, width: number, height: number, type: string }>} */ async decodeArtemisGIF(gifBuf) { const appExtensions = this.findAppExtensions(gifBuf); const artemisExt = appExtensions.find((ext) => ext.appName === 'Artemis ' && (ext.authCode === 'ALP' || ext.authCode === 'ALF')); if (!artemisExt) throw new Error('GIF does not contain an Artemis ALP/ALF block'); const type = artemisExt.authCode; const alphaTable = artemisExt.data; const hdr = this.parseGIFHeader(gifBuf); // Find first image descriptor and decode indices for alpha lookup let scanPos = 13 + hdr.gctBytes; while (scanPos < gifBuf.length) { const b = gifBuf[scanPos]; if (b === 0x2C) break; // image descriptor if (b === 0x3B) throw new Error('No image descriptor found in GIF'); if (b === 0x21) { scanPos += 2; const label = gifBuf[scanPos - 1]; if (label === 0xF9) { const gceBlockSize = gifBuf[scanPos]; scanPos += 1 + gceBlockSize + 1; } else if (label === 0xFF) { const appBlockSize = gifBuf[scanPos]; scanPos += 1 + appBlockSize; while (scanPos < gifBuf.length && gifBuf[scanPos] !== 0) { scanPos += gifBuf[scanPos] + 1; } scanPos++; } else { while (scanPos < gifBuf.length && gifBuf[scanPos] !== 0) { scanPos += gifBuf[scanPos] + 1; } scanPos++; } continue; } scanPos++; } if (scanPos >= gifBuf.length) throw new Error('Could not find image descriptor'); const imgDescStart = scanPos; const imgDescPacked = gifBuf[imgDescStart + 9]; const hasLCT = (imgDescPacked & 0x80) !== 0; const lctSize = hasLCT ? (1 << ((imgDescPacked & 0x07) + 1)) : 0; const lzwStart = imgDescStart + 10 + lctSize * 3; const minCodeSize = gifBuf[lzwStart]; const width = hdr.width; const height = hdr.height; const pixelCount = width * height; const { data: rawLZWData } = this.readSubBlocks(gifBuf, lzwStart + 1); const indices = this.lzwDecode(rawLZWData, minCodeSize, pixelCount); const origImg = await sharp(gifBuf).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); const origData = origImg.data; if (origData.length !== pixelCount * 4) { throw new Error('Unexpected original image buffer size'); } const rgba = Buffer.from(origData); for (let i = 0; i < pixelCount; i++) { const idx = indices[i]; if (type === 'ALP') { rgba[i * 4 + 3] = (idx === 0) ? 0x00 : ((idx - 1 < alphaTable.length) ? alphaTable[idx - 1] : 0xFF); } else { rgba[i * 4 + 3] = (idx < alphaTable.length) ? alphaTable[idx] : 0xFF; } } return { rgba, width, height, type }; } /** * Quantize an RGBA image into a GIF palette and extract the real palette * and alpha table using gifski-style heuristics. * * This function uses sharp to produce a color-indexed GIF, then rebuilds * the true RGB palette and per-index alpha values from the original pixels. * It is intentionally dependency-light and avoids requiring native imagequant * bindings or experimental Node flags. */ async quantizeArtemisRGBA(rgbaData, width, height, opts) { const pixelCount = width * height; // Use sharp's PNG palette mode (libimagequant) instead of its GIF // encoder. GIF only supports 1-bit alpha so its quantizer collapses // partial-alpha pixels to fully-opaque or fully-transparent before // clustering, destroying the per-pixel alpha we need to reconstruct. // libimagequant under the PNG path clusters in true 4D RGBA space // and gives us per-pixel palette indices we can hand to our own // alpha-histogram pass. const pngOpts = { palette: true, colors: Math.max(2, Math.min(256, opts.colors || 256)), // Carry through dither / effort if the caller specified them. dither: (opts.imgopts && typeof opts.imgopts.dither === 'number') ? opts.imgopts.dither : 1.0, effort: (opts.imgopts && typeof opts.imgopts.effort === 'number') ? opts.imgopts.effort : 7, compressionLevel: 0, }; const quantizedPNGBuf = await sharp(rgbaData, { raw: { width, height, channels: 4 } }) .png(pngOpts) .toBuffer(); const { palette: rawPalette, indices, colors } = this.extractPalettePNG(quantizedPNGBuf); const rSums = new Float64Array(colors); const gSums = new Float64Array(colors); const bSums = new Float64Array(colors); const wSums = new Float64Array(colors); const alphaHists = Array.from({ length: colors }, () => new Uint32Array(256)); const counts = new Uint32Array(colors); for (let i = 0; i < pixelCount; i++) { const idx = indices[i]; if (idx >= colors) continue; const p = i * 4; const a = rgbaData[p + 3]; const w = a / 255; rSums[idx] += rgbaData[p] * w; gSums[idx] += rgbaData[p + 1] * w; bSums[idx] += rgbaData[p + 2] * w; wSums[idx] += w; alphaHists[idx][a] += 1; counts[idx]++; } const realPalette = Buffer.alloc(colors * 3, 0); const alphaTable = Buffer.alloc(colors, 0xFF); for (let i = 0; i < colors; i++) { if (counts[i] === 0) continue; if (wSums[i] > 0) { realPalette[i * 3] = Math.round(rSums[i] / wSums[i]); realPalette[i * 3 + 1] = Math.round(gSums[i] / wSums[i]); realPalette[i * 3 + 2] = Math.round(bSums[i] / wSums[i]); } const hist = alphaHists[i]; const total = counts[i]; let modeAlpha = 0; let modeCount = -1; for (let a = 0; a <= 255; a++) { const c = hist[a]; if (c > modeCount || (c === modeCount && a === 255)) { modeCount = c; modeAlpha = a; } } let opaqueCount = 0; for (let a = 240; a <= 255; a++) opaqueCount += hist[a]; if (total > 0 && (opaqueCount / total) >= 0.50) { alphaTable[i] = 255; continue; } if (total > 0 && (hist[0] / total) >= 0.50) { alphaTable[i] = 0; continue; } let chosen = modeAlpha; if (chosen >= 252) chosen = 255; else chosen = chosen & 0xF8; alphaTable[i] = chosen; } let bestZeroIdx = -1; let bestZeroCount = 0; for (let i = 0; i < colors; i++) { if (alphaTable[i] === 0 && counts[i] > bestZeroCount) { bestZeroIdx = i; bestZeroCount = counts[i]; } } return { colors, indices, realPalette, alphaTable, bestZeroIdx }; } /** * Encode an RGBA image (raw Buffer or sharp-compatible input) into a WebTV * Artemis ALF GIF. * * Steps: * 1. Quantize the image to a ≤256-color palette, extracting per-palette-entry * average alpha. * 2. Build a GIF89a with the Artemis ALF application extension block. * 3. The alpha lookup table is stored as the app extension payload. * * @param {Buffer|string} input - raw RGBA buffer, file path, or any sharp-compatible source * @param {object} [opts] * @param {number} [opts.colors=256] - palette size (must be power of 2, 2-256) * @param {'ALP'|'ALF'} [opts.type='ALF'] * @returns {Promise} - GIF89a file contents */ async encodeArtemisGIF(input, opts = {}) { const paletteSize = opts.colors || 256; const type = opts.type || 'ALF'; const sharpSrc = (typeof input === 'string' || Buffer.isBuffer(input)) ? sharp(input) : input; const { data: rgbaData, info } = await sharpSrc .ensureAlpha() .raw() .toBuffer({ resolveWithObject: true }); const { width, height } = info; const { colors, indices, realPalette, alphaTable, bestZeroIdx } = await this.quantizeArtemisRGBA(rgbaData, width, height, opts); const transparentIdx = (type === 'ALF') ? colors - 1 : 0; const finalIndices = Buffer.from(indices); const fullAlpha = Buffer.from(alphaTable); if (bestZeroIdx >= 0 && bestZeroIdx !== transparentIdx) { const tmpR = realPalette[transparentIdx * 3]; const tmpG = realPalette[transparentIdx * 3 + 1]; const tmpB = realPalette[transparentIdx * 3 + 2]; realPalette[transparentIdx * 3] = realPalette[bestZeroIdx * 3]; realPalette[transparentIdx * 3 + 1] = realPalette[bestZeroIdx * 3 + 1]; realPalette[transparentIdx * 3 + 2] = realPalette[bestZeroIdx * 3 + 2]; realPalette[bestZeroIdx * 3] = tmpR; realPalette[bestZeroIdx * 3 + 1] = tmpG; realPalette[bestZeroIdx * 3 + 2] = tmpB; const tmpA = fullAlpha[transparentIdx]; fullAlpha[transparentIdx] = fullAlpha[bestZeroIdx]; fullAlpha[bestZeroIdx] = tmpA; for (let i = 0; i < finalIndices.length; i++) { const v = finalIndices[i]; if (v === transparentIdx) finalIndices[i] = bestZeroIdx; else if (v === bestZeroIdx) finalIndices[i] = transparentIdx; } } if (bestZeroIdx >= 0) { realPalette[transparentIdx * 3] = 0; realPalette[transparentIdx * 3 + 1] = 0; realPalette[transparentIdx * 3 + 2] = 0; } const emitAlphaTable = this.buildArtemisAlphaTable(type, fullAlpha); const hasTransparent = bestZeroIdx >= 0; // Re-encode the LZW image stream from our (possibly swapped) indices. const newMinCodeSize = Math.max(2, Math.ceil(Math.log2(colors))); const lzwEncoded = this.lzwEncode(finalIndices, newMinCodeSize); const lzwBlocks = this.wrapSubBlocks(lzwEncoded); const imgDesc = Buffer.alloc(10); imgDesc[0] = 0x2C; imgDesc.writeUInt16LE(0, 1); imgDesc.writeUInt16LE(0, 3); imgDesc.writeUInt16LE(width, 5); imgDesc.writeUInt16LE(height, 7); imgDesc[9] = 0x00; const appExtBlock = this.buildAppExtension('Artemis ', type, emitAlphaTable); const gceBlock = this.buildGCE(transparentIdx, hasTransparent); const gifHeader = this.buildGIFHeader(width, height, colors, 0); return Buffer.concat([ gifHeader, realPalette, gceBlock, appExtBlock, imgDesc, Buffer.from([newMinCodeSize]), lzwBlocks, Buffer.from([0x3B]) ]); } // --------------------------------------------------------------------------- // Minimal PNG chunk parser (for palette/indexed PNGs) // --------------------------------------------------------------------------- /** Walk a PNG buffer and return a Map of chunkType -> Buffer[] (data only). */ parsePNGChunks(buf) { const PNG_SIG = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); if (!buf.slice(0, 8).equals(PNG_SIG)) throw new Error('Not a PNG file'); const chunks = new Map(); let offset = 8; while (offset < buf.length) { const length = buf.readUInt32BE(offset); offset += 4; const type = buf.slice(offset, offset + 4).toString('ascii'); offset += 4; const data = buf.slice(offset, offset + length); offset += length; offset += 4; // skip CRC if (!chunks.has(type)) chunks.set(type, []); chunks.get(type).push(data); } return chunks; } /** * Check if a PNG buffer is an indexed-color (palette) PNG. * Color type 3 = indexed color. Byte 25 of the file = color type in IHDR. * @param {Buffer} pngBuf * @returns {boolean} */ isPalettePNG(pngBuf) { if (pngBuf.length < 26) return false; // PNG sig(8) + chunk length(4) + 'IHDR'(4) + width(4) + height(4) + bitdepth(1) + colortype(1) return pngBuf[25] === 3; } /** * Extract palette, alpha table, pixel indices, width, and height from an * indexed-color PNG. Handles bit depths 1, 2, 4, and 8. * * @param {Buffer} pngBuf * @returns {{ palette: Buffer, alphaTable: Buffer, indices: Uint8Array, * width: number, height: number, colors: number }} */ extractPalettePNG(pngBuf) { const chunks = this.parsePNGChunks(pngBuf); if (!chunks.has('IHDR')) throw new Error('PNG missing IHDR chunk'); if (!chunks.has('PLTE')) throw new Error('PNG missing PLTE chunk (not a palette PNG)'); const ihdr = chunks.get('IHDR')[0]; const width = ihdr.readUInt32BE(0); const height = ihdr.readUInt32BE(4); const bitDepth = ihdr[8]; const colorType = ihdr[9]; const interlace = ihdr[12]; if (colorType !== 3) throw new Error('PNG is not indexed-color (color type 3)'); if (interlace !== 0) throw new Error('Interlaced palette PNGs are not supported'); const palette = chunks.get('PLTE')[0]; // RGB triplets const colors = palette.length / 3; // tRNS gives per-palette-entry alpha (may be shorter than palette) const tRNSData = chunks.has('tRNS') ? chunks.get('tRNS')[0] : Buffer.alloc(0); const alphaTable = Buffer.alloc(colors, 0xFF); // default opaque for (let i = 0; i < tRNSData.length && i < colors; i++) { // Quantize to multiples of 8 (reference ALP convention). let a = tRNSData[i]; if (a >= 252) a = 255; else a = a & 0xF8; alphaTable[i] = a; } // Decompress IDAT const idatData = Buffer.concat(chunks.get('IDAT')); const raw = zlib.inflateSync(idatData); // Un-filter scanlines const bytesPerRow = Math.ceil(width * bitDepth / 8); const indices = new Uint8Array(width * height); let rawOffset = 0; let prevRow = Buffer.alloc(bytesPerRow, 0); for (let y = 0; y < height; y++) { const filterType = raw[rawOffset++]; const row = raw.slice(rawOffset, rawOffset + bytesPerRow); rawOffset += bytesPerRow; const recon = Buffer.alloc(bytesPerRow); for (let i = 0; i < bytesPerRow; i++) { const x = row[i]; const a = i >= 1 ? recon[i - 1] : 0; // left const b = prevRow[i]; // above const c = i >= 1 ? prevRow[i - 1] : 0; // above-left switch (filterType) { case 0: recon[i] = x; break; // None case 1: recon[i] = (x + a) & 0xFF; break; // Sub case 2: recon[i] = (x + b) & 0xFF; break; // Up case 3: recon[i] = (x + Math.floor((a + b) / 2)) & 0xFF; break; // Average case 4: { // Paeth const p = a + b - c; const pa = Math.abs(p - a); const pb = Math.abs(p - b); const pc = Math.abs(p - c); recon[i] = (x + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c)) & 0xFF; break; } default: throw new Error(`Unknown PNG filter type ${filterType}`); } } // Unpack bits to per-pixel indices for (let x = 0; x < width; x++) { if (bitDepth === 8) { indices[y * width + x] = recon[x]; } else { const byteIdx = Math.floor(x * bitDepth / 8); const bitShift = 8 - bitDepth - (x * bitDepth % 8); const mask = (1 << bitDepth) - 1; indices[y * width + x] = (recon[byteIdx] >> bitShift) & mask; } } prevRow = recon; } // Round palette color count up to the next valid GIF power-of-two const validSizes = [2, 4, 8, 16, 32, 64, 128, 256]; const gifColors = validSizes.find(s => s >= colors) || 256; // Pad palette and alpha table to gifColors entries if needed const paddedPalette = Buffer.alloc(gifColors * 3, 0); palette.copy(paddedPalette, 0, 0, Math.min(palette.length, gifColors * 3)); const paddedAlpha = Buffer.alloc(gifColors, 0); alphaTable.copy(paddedAlpha, 0, 0, Math.min(alphaTable.length, gifColors)); return { palette: paddedPalette, alphaTable: paddedAlpha, indices, width, height, colors: gifColors }; } /** * Encode an already-decoded palette PNG directly into an Artemis GIF without * re-quantization. The original PLTE palette and tRNS alpha table are used 1:1. * * @param {Buffer} pngBuf * @param {object} [opts] * @param {'ALP'|'ALF'} [opts.type='ALP'] * @returns {Promise} */ async paletteImageToArtemisGIF(pngBuf, opts = {}) { const type = opts.type || 'ALP'; const { palette, alphaTable, indices, width, height, colors } = this.extractPalettePNG(pngBuf); const transparentIdx = (type === 'ALF') ? colors - 1 : 0; // Find first palette entry with alpha=0 and swap it into the expected // transparent colour slot for the selected Artemis type. let zeroIdx = -1; for (let i = 0; i < colors; i++) { if (alphaTable[i] === 0) { zeroIdx = i; break; } } const finalIndices = Buffer.from(indices); const finalPalette = Buffer.from(palette); const finalAlpha = Buffer.from(alphaTable); if (zeroIdx >= 0 && zeroIdx !== transparentIdx) { const tmpR = finalPalette[transparentIdx * 3], tmpG = finalPalette[transparentIdx * 3 + 1], tmpB = finalPalette[transparentIdx * 3 + 2]; finalPalette[transparentIdx * 3] = finalPalette[zeroIdx * 3]; finalPalette[transparentIdx * 3 + 1] = finalPalette[zeroIdx * 3 + 1]; finalPalette[transparentIdx * 3 + 2] = finalPalette[zeroIdx * 3 + 2]; finalPalette[zeroIdx * 3] = tmpR; finalPalette[zeroIdx * 3 + 1] = tmpG; finalPalette[zeroIdx * 3 + 2] = tmpB; const tmpA = finalAlpha[transparentIdx]; finalAlpha[transparentIdx] = finalAlpha[zeroIdx]; finalAlpha[zeroIdx] = tmpA; for (let i = 0; i < finalIndices.length; i++) { const v = finalIndices[i]; if (v === transparentIdx) finalIndices[i] = zeroIdx; else if (v === zeroIdx) finalIndices[i] = transparentIdx; } } if (zeroIdx >= 0) { finalPalette[transparentIdx * 3] = 0; finalPalette[transparentIdx * 3 + 1] = 0; finalPalette[transparentIdx * 3 + 2] = 0; } // Emit full alphaTable (no truncation; WebTV may default missing // entries to 0x00 transparent rather than 0xFF opaque). const emitAlphaTable = this.buildArtemisAlphaTable(type, finalAlpha); const minCodeSize = Math.max(2, Math.ceil(Math.log2(colors))); const appExtBlock = this.buildAppExtension('Artemis ', type, emitAlphaTable); const gceBlock = this.buildGCE(transparentIdx, zeroIdx >= 0); const lzwEncoded = this.lzwEncode(finalIndices, minCodeSize); const lzwBlocks = this.wrapSubBlocks(lzwEncoded); const imgDesc = Buffer.alloc(10); imgDesc[0] = 0x2C; imgDesc.writeUInt16LE(0, 1); imgDesc.writeUInt16LE(0, 3); imgDesc.writeUInt16LE(width, 5); imgDesc.writeUInt16LE(height, 7); imgDesc[9] = 0x00; return Buffer.concat([ this.buildGIFHeader(width, height, colors, 0), finalPalette, gceBlock, appExtBlock, imgDesc, Buffer.from([minCodeSize]), lzwBlocks, Buffer.from([0x3B]) ]); } // --------------------------------------------------------------------------- // PNG → WebTV format router // --------------------------------------------------------------------------- /** * Convert a PNG to the appropriate WebTV-compatible format: * - PNG without alpha → JPEG * - Palette PNG (color type 3) with alpha → Artemis ALF GIF * - Full-color RGBA PNG → quantized Artemis ALF GIF * * @param {string|Buffer} input - file path or raw PNG Buffer * @param {object} [opts] * @param {number} [opts.colors=256] - palette size for full-color quantization * @param {'ALP'|'ALF'} [opts.type='ALP'] - Artemis variant * @param {number} [opts.jpegQuality=85] - JPEG quality (0-100) when no alpha * @param {number} [opts.maxWidth] - maximum width to scale to before encoding * @param {number} [opts.maxHeight] - maximum height to scale to before encoding * @returns {Promise<{ data: Buffer, mime: string }>} */ async ImageToWebTV(input, opts = {}) { let pngBuf = Buffer.isBuffer(input) ? input : require('fs').readFileSync(input); const maxWidth = Number(opts.maxWidth) > 0 ? Number(opts.maxWidth) : null; const maxHeight = Number(opts.maxHeight) > 0 ? Number(opts.maxHeight) : null; const originalIsPalettePNG = this.isPalettePNG(pngBuf); const inputMeta = await sharp(pngBuf).metadata(); const willResize = (maxWidth && inputMeta.width > maxWidth) || (maxHeight && inputMeta.height > maxHeight); if (willResize) { const resizeOpts = { fit: 'inside', withoutEnlargement: true }; if (maxWidth) resizeOpts.width = maxWidth; if (maxHeight) resizeOpts.height = maxHeight; pngBuf = await sharp(pngBuf) .resize(resizeOpts) .png() .toBuffer(); } const meta = willResize ? await sharp(pngBuf).metadata() : inputMeta; let usesAlpha = false; if (meta.hasAlpha) { // Many PNG files include an alpha channel that is fully opaque. // Treat those as non-alpha images and keep JPEG path. try { const stats = await sharp(pngBuf).stats(); if (stats.channels && stats.channels[3]) { usesAlpha = stats.channels[3].min < 255; } } catch (e) { // Fallback to channel presence when stats cannot be computed. usesAlpha = true; } } if (!meta.hasAlpha || !usesAlpha) { // No alpha channel → JPEG const data = await sharp(pngBuf) .jpeg({ quality: opts.jpegQuality || 85 }) .toBuffer(); return { data, mime: 'image/jpeg' }; } if (this.isPalettePNG(pngBuf)) { // Palette/indexed PNGs should preserve palette + tRNS alpha exactly by default. // If resizing was applied, the palette is no longer preserved and we must // re-quantize the image before producing an Artemis GIF. const forceRequantize = opts.forceRequantizePalette || willResize; const pngInfo = this.extractPalettePNG(pngBuf); if (!forceRequantize && this.isSimplePaletteTransparency(pngInfo.alphaTable)) { const data = this.encodePalettePNGAsStandardGIF(pngInfo); return { data, mime: 'image/gif' }; } const data = forceRequantize ? await this.encodeArtemisGIF(pngBuf, opts) : await this.paletteImageToArtemisGIF(pngBuf, opts); return { data, mime: 'image/gif' }; } // Full-color RGBA → quantize const data = await this.encodeArtemisGIF(pngBuf, opts); return { data, mime: 'image/gif' }; } async ImageToArtemisGIF(input, opts = {}) { const result = await this.ImageToWebTV(input, opts); if (result.mime !== 'image/gif') throw new Error('Input image has no alpha; cannot encode as Artemis GIF. Use ImageToWebTV() instead.'); return result.data; } /** * Convert a WebTV Artemis ALP/ALF GIF to a standard RGBA PNG. * * @param {string|Buffer} input - file path or raw Buffer * @returns {Promise} PNG file contents */ async artemisGIFtoPNG(input) { const gifBuf = Buffer.isBuffer(input) ? input : require('fs').readFileSync(input); const { rgba, width, height } = await this.decodeArtemisGIF(gifBuf); return sharp(rgba, { raw: { width, height, channels: 4 } }) .png() .toBuffer(); } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Detect whether a GIF buffer is a WebTV Artemis alpha GIF. * @param {Buffer} gifBuf * @returns {'ALP'|'ALF'|null} */ static detect(gifBuf) { return WTVImage._impl.detectArtemisType(gifBuf); } /** * Decode a WebTV Artemis ALP/ALF GIF to raw RGBA pixel data. * @param {Buffer} gifBuf * @returns {Promise<{ rgba: Buffer, width: number, height: number, type: string }>} */ static decode(gifBuf) { return WTVImage._impl.decodeArtemisGIF(gifBuf); } /** * Encode raw RGBA image data (or any sharp-compatible source) into a WebTV * Artemis ALP/ALF GIF. * @param {Buffer|string} input * @param {object} [opts] * @param {number} [opts.colors=256] * @param {'ALP'|'ALF'} [opts.type='ALP'] * @returns {Promise} */ static encode(input, opts = {}) { return WTVImage._impl.encodeArtemisGIF(input, opts); } /** * Convert an unsupported image to the appropriate WebTV format. * @param {string|Buffer} input * @param {object} [opts] * @returns {Promise<{ data: Buffer, mime: string }>} */ static ImageToWebTV(input, opts = {}) { return WTVImage._impl.ImageToWebTV(input, opts); } /** * Convert a image with alpha to a WebTV Artemis GIF. * Throws if the input has no alpha channel. * @param {string|Buffer} input * @param {object} [opts] * @param {number} [opts.colors=256] * @param {'ALP'|'ALF'} [opts.type='ALP'] * @returns {Promise} */ static ImageToGIF(input, opts = {}) { return WTVImage._impl.ImageToArtemisGIF(input, opts); } /** * Convert a WebTV Artemis ALP/ALF GIF to a standard RGBA PNG. * @param {string|Buffer} input * @returns {Promise} */ static gifToPNG(input) { return WTVImage._impl.artemisGIFtoPNG(input); } } WTVImage._impl = new WTVImage(); module.exports = WTVImage;