From 572f522262a1fe86fcfca073a9b8ff39c9502d52 Mon Sep 17 00:00:00 2001 From: zefie Date: Sun, 26 Apr 2026 02:31:52 -0400 Subject: [PATCH] WTVPNG --- zefie_wtvp_minisrv/WebTVLogoJewel.png | Bin 0 -> 7036 bytes zefie_wtvp_minisrv/app.js | 23 + zefie_wtvp_minisrv/includes/classes/WTVPNG.js | 1094 +++++++++++++++++ zefie_wtvp_minisrv/includes/config.json | 2 + zefie_wtvp_minisrv/wtv_png_converter.js | 199 +++ 5 files changed, 1318 insertions(+) create mode 100644 zefie_wtvp_minisrv/WebTVLogoJewel.png create mode 100644 zefie_wtvp_minisrv/includes/classes/WTVPNG.js create mode 100644 zefie_wtvp_minisrv/wtv_png_converter.js diff --git a/zefie_wtvp_minisrv/WebTVLogoJewel.png b/zefie_wtvp_minisrv/WebTVLogoJewel.png new file mode 100644 index 0000000000000000000000000000000000000000..acfa46cdb083b82fee3fcdcd5df1f9e19d2f0bd9 GIT binary patch literal 7036 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 aar{4Qr+5A-mCWq`0000 99) { if (minisrv_config.config.shenanigans) console.log(" * WARNING: Shenanigans level", minisrv_config.config.shenanigans, "enabled"); else console.log(" * Shenanigans disabled"); +// PNG +if (minisrv_config.config.decode_png) console.log(" * PNG will be processed for WebTV clients"); +else console.log(" * PNG will not be processed, and sent to client as-is"); + + + process.on('uncaughtException', function (err) { console.error((err && err.stack) ? err.stack : err); }); diff --git a/zefie_wtvp_minisrv/includes/classes/WTVPNG.js b/zefie_wtvp_minisrv/includes/classes/WTVPNG.js new file mode 100644 index 00000000..effb4656 --- /dev/null +++ b/zefie_wtvp_minisrv/includes/classes/WTVPNG.js @@ -0,0 +1,1094 @@ +/** + * WTVPNG - 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. + * ALP encoding does not yet work correctly. + * 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 WTVPNG { + // --------------------------------------------------------------------------- + // 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 }; + } + + // --------------------------------------------------------------------------- + // 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]; + rgba[i * 4 + 3] = (idx < alphaTable.length) ? alphaTable[idx] : 0xFF; + } + + return { rgba, width, height, type }; + } + + /** + * 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'; + + // 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 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 + } + + // No dithering: dithering would scatter alpha-encoded virtual colors across + // entries, corrupting the alpha-per-index mapping. + 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; + + // Find image descriptor, skipping any extensions + 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 preImageExt = quantizedGIFBuf.slice(13 + qHdr.gctBytes, scanPos); + 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); + + // Rebuild the real palette and alpha table from original source pixels. + // RGB uses alpha-weighted averaging so transparent pixels (often undefined RGB) + // do not skew color. Alpha uses robust histogram quantiles to reduce halos. + const rSums = new Float64Array(colors); + const gSums = new Float64Array(colors); + const bSums = new Float64Array(colors); + const wSums = new Float64Array(colors); // alpha-weight sums for RGB + 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; // weight by alpha + 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; + // For RGB: use alpha-weighted average so transparent pixels don't skew colors. + // For fully-transparent entries (wSums[i]≈0) color doesn't matter, leave black. + 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; + } + } + + // If the cluster is mostly opaque (>=50% α≥240), snap to 255. + // Protects dialog/text content from anti-aliased edge bleed without + // collapsing genuinely-translucent dialog body content. + 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 the cluster is mostly fully-transparent (>=50% α=0), snap to 0. + 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; + } + + // Emit the full alphaTable (no truncation). While reference WebTV ROM + // Artemis GIFs do truncate trailing 0xFF entries, the WebTV renderer + // appears to default missing entries to 0x00 (transparent), not 0xFF + // (opaque), so any opaque palette entry past the truncation point + // would render invisible. Always emit all `colors` entries. + let alphaLen = alphaTable.length; + const trimmedAlphaTable = alphaTable.slice(0, alphaLen); + + // Find palette index whose alphaTable[idx]===0 with the most pixels. + // Use the correct transparent palette slot for ALF vs ALP. + const transparentIdx = (type === 'ALF') ? colors - 1 : 0; + 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]; + } + } + + const finalIndices = Buffer.from(indices); + const fullAlpha = Buffer.from(alphaTable); + if (bestZeroIdx >= 0 && bestZeroIdx !== transparentIdx) { + // Swap the transparent palette entry into the expected slot. + 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 = 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 palettePNGToArtemisGIF(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 = 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 + * @returns {Promise<{ data: Buffer, mime: string }>} + */ + async pngToWebTV(input, opts = {}) { + const pngBuf = Buffer.isBuffer(input) ? input : require('fs').readFileSync(input); + 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. + // Allow forcing re-quantization only when explicitly requested. + const data = opts.forceRequantizePalette + ? await this.encodeArtemisGIF(pngBuf, opts) + : await this.palettePNGToArtemisGIF(pngBuf, opts); + return { data, mime: 'image/gif' }; + } + + // Full-color RGBA → quantize + const data = await this.encodeArtemisGIF(pngBuf, opts); + return { data, mime: 'image/gif' }; + } + + async pngToArtemisGIF(input, opts = {}) { + const result = await this.pngToWebTV(input, opts); + if (result.mime !== 'image/gif') throw new Error('Input PNG has no alpha; cannot encode as Artemis GIF. Use pngToWebTV() 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 WTVPNG._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 WTVPNG._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 WTVPNG._impl.encodeArtemisGIF(input, opts); + } + + /** + * Convert a PNG to the appropriate WebTV format. + * @param {string|Buffer} input + * @param {object} [opts] + * @returns {Promise<{ data: Buffer, mime: string }>} + */ + static pngToWebTV(input, opts = {}) { + return WTVPNG._impl.pngToWebTV(input, opts); + } + + /** + * Convert a PNG 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 pngToGIF(input, opts = {}) { + return WTVPNG._impl.pngToArtemisGIF(input, opts); + } + + /** + * Convert a WebTV Artemis ALP/ALF GIF to a standard RGBA PNG. + * @param {string|Buffer} input + * @returns {Promise} + */ + static gifToPNG(input) { + return WTVPNG._impl.artemisGIFtoPNG(input); + } +} + +WTVPNG._impl = new WTVPNG(); + +module.exports = WTVPNG; diff --git a/zefie_wtvp_minisrv/includes/config.json b/zefie_wtvp_minisrv/includes/config.json index df260909..09a3e73b 100644 --- a/zefie_wtvp_minisrv/includes/config.json +++ b/zefie_wtvp_minisrv/includes/config.json @@ -22,6 +22,8 @@ "cgi_enabled": false, // Disable CGI by default "php_enabled": false, // Disable PHP by default "php_binpath": "php-cgi", + "decode_png": true, // Attempt to decode PNG into JPG/ALP/ALF/GIF + "decode_png_jpeg_quality": 75, // JPEG quality for decoded PNGs, 0-100 lower is worse quality but smaller files. "SessionStore": "SessionStore", // Where we store account (session) data. Best left unchanged. "SharedROMCache": "SharedROMCache", // Shared ROMCache (wtv-service:/ROMCache/, where wtv-service is any configured service). Found under service vault. Best left unchanged. "enable_shared_romcache": true, // Disabling this will cause a lot of problems without manual intervention. Best left unchanged. diff --git a/zefie_wtvp_minisrv/wtv_png_converter.js b/zefie_wtvp_minisrv/wtv_png_converter.js new file mode 100644 index 00000000..49586d8f --- /dev/null +++ b/zefie_wtvp_minisrv/wtv_png_converter.js @@ -0,0 +1,199 @@ +#!/usr/bin/env node +/** + * wtv_png_converter.js - WebTV PNG/GIF conversion CLI + * + * Usage: + * node wtv_png_converter.js [options] [output] + * + * Commands: + * convert Convert a PNG to the best WebTV format (auto: JPEG or Artemis GIF) + * encode Convert a PNG with alpha to an Artemis ALP/ALF GIF + * decode Convert a WebTV Artemis ALP/ALF GIF back to a PNG + * detect Report whether a GIF contains an Artemis ALP/ALF block + * + * Options: + * --type Artemis variant to use for encoding (default: ALP) + * --colors Palette size for full-color quantization (default: 256) + * --quality JPEG quality when output is JPEG (default: 85) + * --output, -o Output file path (alternative to positional argument) + * --help, -h Show this help + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const WTVPNG = require('./includes/classes/WTVPNG'); + +// --------------------------------------------------------------------------- +// Argument parser +// --------------------------------------------------------------------------- +function parseArgs(argv) { + const args = { options: {}, positional: [] }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--help' || a === '-h') { + args.options.help = true; + } else if (a === '--type') { + args.options.type = argv[++i]; + } else if (a === '--colors') { + args.options.colors = parseInt(argv[++i], 10); + } else if (a === '--quality') { + args.options.quality = parseInt(argv[++i], 10); + } else if (a === '--output' || a === '-o') { + args.options.output = argv[++i]; + } else if (a.startsWith('--')) { + console.error(`Unknown option: ${a}`); + process.exit(1); + } else { + args.positional.push(a); + } + } + return args; +} + +function printHelp() { + console.log(` +WebTV PNG/GIF Converter +======================= +Usage: node wtv_png_converter.js [options] [output] + +Commands: + convert Convert a PNG to the best WebTV format + - No alpha → JPEG + - Palette PNG → Artemis GIF (palette 1:1, no requantization) + - Full-color RGBA → Artemis GIF (quantized) + + encode Convert a PNG with alpha to an Artemis ALP or ALF GIF + (throws if the PNG has no alpha channel) + + decode Convert a WebTV Artemis ALP/ALF GIF back to a standard RGBA PNG + + detect Report whether a file is an Artemis ALP/ALF GIF (no output file needed) + +Options: + --type Artemis variant for encoding/convert (default: ALP) + --colors Palette size for full-color quantization (default: 256) + --quality JPEG quality when output is JPEG (default: 85) + --output, -o Output file path + --help, -h Show this help + +Examples: + node wtv_png_converter.js convert logo.png + node wtv_png_converter.js convert logo.png logo_wtv.gif --type ALF --colors 128 + node wtv_png_converter.js encode icon.png icon.gif --type ALP + node wtv_png_converter.js decode artemis.gif result.png + node wtv_png_converter.js detect artemis.gif +`.trim()); +} + +// --------------------------------------------------------------------------- +// Output path helpers +// --------------------------------------------------------------------------- +function resolveOutput(inputFile, suggestedExt, override) { + if (override) return override; + const base = path.join( + path.dirname(inputFile), + path.basename(inputFile, path.extname(inputFile)) + ); + return base + suggestedExt; +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- +async function cmdConvert(inputFile, outputFile, opts) { + const pngBuf = fs.readFileSync(inputFile); + const { data, mime } = await WTVPNG.pngToWebTV(pngBuf, { + type: opts.type || 'ALP', + colors: opts.colors || 256, + jpegQuality: opts.quality || 85 + }); + + const ext = mime === 'image/gif' ? '.gif' : '.jpg'; + const dest = resolveOutput(inputFile, ext, outputFile); + fs.writeFileSync(dest, data); + console.log(`[convert] ${inputFile} → ${dest} (${mime}, ${data.length} bytes)`); +} + +async function cmdEncode(inputFile, outputFile, opts) { + const pngBuf = fs.readFileSync(inputFile); + const gifBuf = await WTVPNG.pngToGIF(pngBuf, { + type: opts.type || 'ALP', + colors: opts.colors || 256 + }); + + const dest = resolveOutput(inputFile, '.gif', outputFile); + fs.writeFileSync(dest, gifBuf); + const type = WTVPNG.detect(gifBuf); + console.log(`[encode] ${inputFile} → ${dest} (Artemis ${type}, ${gifBuf.length} bytes)`); +} + +async function cmdDecode(inputFile, outputFile, opts) { + const gifBuf = fs.readFileSync(inputFile); + const type = WTVPNG.detect(gifBuf); + if (!type) { + console.error(`[decode] ${inputFile} does not contain an Artemis ALP/ALF block.`); + process.exit(1); + } + + const pngBuf = await WTVPNG.gifToPNG(gifBuf); + const dest = resolveOutput(inputFile, '.png', outputFile); + fs.writeFileSync(dest, pngBuf); + console.log(`[decode] ${inputFile} (Artemis ${type}) → ${dest} (${pngBuf.length} bytes)`); +} + +function cmdDetect(inputFile) { + const buf = fs.readFileSync(inputFile); + const type = WTVPNG.detect(buf); + if (type) { + console.log(`[detect] ${inputFile} → Artemis ${type}`); + } else { + console.log(`[detect] ${inputFile} → Not an Artemis ALP/ALF GIF`); + } +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- +(async () => { + const raw = process.argv.slice(2); + const args = parseArgs(raw); + + if (args.options.help || args.positional.length === 0) { + printHelp(); + process.exit(0); + } + + const command = args.positional[0]; + const inputFile = args.positional[1]; + const outputFile = args.options.output || args.positional[2] || null; + + if (!inputFile) { + console.error('Error: no input file specified.'); + printHelp(); + process.exit(1); + } + + if (!fs.existsSync(inputFile)) { + console.error(`Error: input file not found: ${inputFile}`); + process.exit(1); + } + + try { + switch (command) { + case 'convert': await cmdConvert(inputFile, outputFile, args.options); break; + case 'encode': await cmdEncode(inputFile, outputFile, args.options); break; + case 'decode': await cmdDecode(inputFile, outputFile, args.options); break; + case 'detect': cmdDetect(inputFile); break; + default: + console.error(`Unknown command: ${command}`); + printHelp(); + process.exit(1); + } + } catch (err) { + console.error(`Error: ${err.message}`); + process.exit(1); + } +})();