diff --git a/zefie_wtvp_minisrv/app.js b/zefie_wtvp_minisrv/app.js index 832ff2ca..4e817463 100644 --- a/zefie_wtvp_minisrv/app.js +++ b/zefie_wtvp_minisrv/app.js @@ -1001,7 +1001,17 @@ async function processURL(socket, request_headers, pc_services = false) { request_headers.query = {}; if (request_headers.request_url) { - service_name = socket.service_name || verifyServicePort(decodeURIComponent(request_headers.request_url).split(':')[0], socket); + try { + // wtv-log handling + request_headers.request_url = request_headers.request_url.replaceAll(/\%\+/g, "%20"); // sanitize parentheses for logging + service_name = socket.service_name || verifyServicePort(decodeURIComponent(request_headers.request_url).split(':')[0], socket); + } catch (err) { + console.log(" * Invalid URI: %s", request_headers.request_url); + console.error((err && err.stack) ? err.stack : err); + const errpage = wtvshared.doErrorPage(400, null, null, pc_services); + sendToClient(socket, errpage[0], errpage[1]); + return; + } if (minisrv_config.services[service_name]) { allow_double_slash = minisrv_config.services[service_name].allow_double_slash || false; enable_multi_query = minisrv_config.services[service_name].enable_multi_query || false; @@ -1314,13 +1324,16 @@ async function sendToClient(socket, headers_obj, data = null) { } - let imageArtemisType = 'ALP' + let imageArtemisType = minisrv_config.config.image_decoder.gif_type || 'ALP'; // Add last modified if not a dynamic script if (socket_sessions[socket.id]) { if (socket_sessions[socket.id].request_headers) { if (socket_sessions[socket.id].request_headers.query) { - if (socket_sessions[socket.id].request_headers.query.forceALF) { - imageArtemisType = 'ALF'; + if (socket_sessions[socket.id].request_headers.query.type === "ALF" || + socket_sessions[socket.id].request_headers.query.type === "ALP" + ) + { + imageArtemisType = socket_sessions[socket.id].request_headers.query.type; } } if (socket_sessions[socket.id].request_headers.service_file_path) { @@ -1365,10 +1378,12 @@ async function sendToClient(socket, headers_obj, data = null) { if (minisrv_config.config.image_decoder.max_height > 0) convertOpts.maxHeight = minisrv_config.config.image_decoder.max_height; if (minisrv_config.config.image_decoder.max_width > 0) convertOpts.maxWidth = minisrv_config.config.image_decoder.max_width; - + if (minisrv_config.config.image_decoder.png_opts) { + pngOpts = minisrv_config.config.image_decoder.png_opts; + } const sourceData = Buffer.isBuffer(data) ? data : Buffer.from(data); try { - const converted = await WTVImage.ImageToWebTV(sourceData, convertOpts); + const converted = await WTVImage.ImageToWebTV(sourceData, convertOpts, pngOpts); data = converted.data; content_length = data.length; var i=0; diff --git a/zefie_wtvp_minisrv/includes/ServiceVault/SharedROMCache/minisrv.gif b/zefie_wtvp_minisrv/includes/ServiceVault/SharedROMCache/minisrv.gif index 11aecf4e..797723bc 100644 Binary files a/zefie_wtvp_minisrv/includes/ServiceVault/SharedROMCache/minisrv.gif and b/zefie_wtvp_minisrv/includes/ServiceVault/SharedROMCache/minisrv.gif differ diff --git a/zefie_wtvp_minisrv/includes/classes/WTVImage.js b/zefie_wtvp_minisrv/includes/classes/WTVImage.js index 06c83e51..97d0e758 100644 --- a/zefie_wtvp_minisrv/includes/classes/WTVImage.js +++ b/zefie_wtvp_minisrv/includes/classes/WTVImage.js @@ -489,6 +489,156 @@ class WTVImage { 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. @@ -525,173 +675,13 @@ class WTVImage { .toBuffer({ resolveWithObject: true }); const { width, height } = info; - const pixelCount = width * height; + const { colors, indices, realPalette, alphaTable, bestZeroIdx } = await this.quantizeArtemisRGBA(rgbaData, width, height, targetColors); - 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]; @@ -986,7 +976,7 @@ class WTVImage { * @param {number} [opts.maxHeight] - maximum height to scale to before encoding * @returns {Promise<{ data: Buffer, mime: string }>} */ - async ImageToWebTV(input, opts = {}) { + 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; @@ -996,7 +986,7 @@ class WTVImage { if (maxHeight) resizeOpts.height = maxHeight; pngBuf = await sharp(pngBuf) .resize(resizeOpts) - .png() + .png(pngopts) .toBuffer(); } const meta = await sharp(pngBuf).metadata(); diff --git a/zefie_wtvp_minisrv/includes/config.json b/zefie_wtvp_minisrv/includes/config.json index e2ffa38b..6b15a192 100644 --- a/zefie_wtvp_minisrv/includes/config.json +++ b/zefie_wtvp_minisrv/includes/config.json @@ -117,6 +117,7 @@ "shenanigans": false, "image_decoder": { "enabled": true, + "gif_type": "ALP", // "ALP" or "ALF", see WTVImage.js for details. "jpg_quality": 75, "image_formats": [ "image/png", @@ -125,6 +126,12 @@ "image/tiff", "image/webp" ], + "png_opts": { + "quality": 80, + "compressionLevel": 9, + "palette": true, + "effort": 10 + }, "max_height": 2048, "max_width": 640, "max_file_size": 524288, diff --git a/zefie_wtvp_minisrv/wtv_img_converter.js b/zefie_wtvp_minisrv/wtv_img_converter.js index e2e2336b..2fb5dbc8 100644 --- a/zefie_wtvp_minisrv/wtv_img_converter.js +++ b/zefie_wtvp_minisrv/wtv_img_converter.js @@ -112,7 +112,7 @@ function resolveOutput(inputFile, suggestedExt, override) { async function cmdConvert(inputFile, outputFile, opts) { const ImageBuf = fs.readFileSync(inputFile); const { data, mime } = await WTVImage.ImageToWebTV(ImageBuf, { - type: opts.type || 'ALF', + type: opts.type || 'ALP', colors: opts.colors || 256, jpegQuality: opts.quality || 85, maxWidth: opts.maxWidth,