Files
minisrv/zefie_wtvp_minisrv/includes/classes/WTVImage.js
2026-04-26 22:47:42 -04:00

1124 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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
*
* TODO: Fix ALP Generation (decoding works but encoding does not yet produce correct ALP files)
*/
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, 2256)
* @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, then trim trailing opaque values
// since ALP app payloads are typically truncated like ALF.
const table = alphaTable.slice(1);
let trimEnd = table.length - 1;
while (trimEnd >= 0 && table[trimEnd] === 0xFF) trimEnd--;
return Buffer.from(table.slice(0, trimEnd + 1));
}
// ---------------------------------------------------------------------------
// 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, targetColors) {
const pixelCount = width * height;
const quantizeData = Buffer.alloc(pixelCount * 4);
for (let i = 0; i < pixelCount; i++) {
const p = i * 4;
const a = rgbaData[p + 3];
let tier;
if (a === 0) tier = 0;
else if (a >= 224) tier = 7;
else tier = 1 + ((a - 1) >> 5);
quantizeData[p] = ((tier & 0x07) << 5) | (rgbaData[p] >> 3);
quantizeData[p + 1] = rgbaData[p + 1];
quantizeData[p + 2] = rgbaData[p + 2];
quantizeData[p + 3] = 255; // sharp's GIF encoder needs alpha=255 to keep all pixels distinct
}
const quantizedGIFBuf = await sharp(quantizeData, { raw: { width, height, channels: 4 } })
.gif({ colors: targetColors, effort: 10, dither: 0 })
.toBuffer();
const qHdr = this.parseGIFHeader(quantizedGIFBuf);
if (!qHdr.hasGCT) throw new Error('Quantized GIF has no global color table');
const colors = qHdr.gctSize;
let scanPos = 13 + qHdr.gctBytes;
while (scanPos < quantizedGIFBuf.length) {
const b = quantizedGIFBuf[scanPos];
if (b === 0x2C) break;
if (b === 0x3B) throw new Error('No image found in quantized GIF');
if (b === 0x21) {
scanPos += 2;
const label = quantizedGIFBuf[scanPos - 1];
if (label === 0xF9) {
const gceBlockSize = quantizedGIFBuf[scanPos];
scanPos += 1 + gceBlockSize + 1;
} else if (label === 0xFF) {
const appBlockSize = quantizedGIFBuf[scanPos];
scanPos += 1 + appBlockSize;
while (scanPos < quantizedGIFBuf.length && quantizedGIFBuf[scanPos] !== 0) {
scanPos += quantizedGIFBuf[scanPos] + 1;
}
scanPos++;
} else {
while (scanPos < quantizedGIFBuf.length && quantizedGIFBuf[scanPos] !== 0) {
scanPos += quantizedGIFBuf[scanPos] + 1;
}
scanPos++;
}
continue;
}
scanPos++;
}
if (scanPos >= quantizedGIFBuf.length) throw new Error('Could not find image descriptor');
const imgDescStart = scanPos;
const imgDescPacked = quantizedGIFBuf[imgDescStart + 9];
const hasLCT = (imgDescPacked & 0x80) !== 0;
const lctSize = hasLCT ? (1 << ((imgDescPacked & 0x07) + 1)) : 0;
const lzwStart = imgDescStart + 10 + lctSize * 3;
const minCodeSize = quantizedGIFBuf[lzwStart];
const { data: rawLZWData } = this.readSubBlocks(quantizedGIFBuf, lzwStart + 1);
const indices = this.lzwDecode(rawLZWData, minCodeSize, pixelCount);
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<Buffer>} - GIF89a file contents
*/
async encodeArtemisGIF(input, opts = {}) {
const paletteSize = opts.colors || 256;
const type = opts.type || 'ALF';
// Clamp palette size to valid GIF power-of-two values
const validSizes = [2, 4, 8, 16, 32, 64, 128, 256];
const targetColors = validSizes.reduce((prev, cur) =>
Math.abs(cur - paletteSize) < Math.abs(prev - paletteSize) ? cur : prev
);
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, targetColors);
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<Buffer>}
*/
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='ALF'] - 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 = {}, pngopts = {}) {
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;
if (maxWidth || maxHeight) {
const resizeOpts = { fit: 'inside', withoutEnlargement: true };
if (maxWidth) resizeOpts.width = maxWidth;
if (maxHeight) resizeOpts.height = maxHeight;
pngBuf = await sharp(pngBuf)
.resize(resizeOpts)
.png(pngopts)
.toBuffer();
}
const meta = await sharp(pngBuf).metadata();
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 || maxWidth || maxHeight;
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<Buffer>} 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<Buffer>}
*/
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<Buffer>}
*/
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<Buffer>}
*/
static gifToPNG(input) {
return WTVImage._impl.artemisGIFtoPNG(input);
}
}
WTVImage._impl = new WTVImage();
module.exports = WTVImage;