From 23e06fc2c0a45ef34bc2f8dd578fe68aeb304121 Mon Sep 17 00:00:00 2001 From: zefie Date: Sun, 26 Apr 2026 22:47:21 -0400 Subject: [PATCH] optimize images --- zefie_wtvp_minisrv/app.js | 27 +- .../ServiceVault/SharedROMCache/minisrv.gif | Bin 5740 -> 5465 bytes .../includes/classes/WTVImage.js | 316 +++++++++--------- zefie_wtvp_minisrv/includes/config.json | 7 + zefie_wtvp_minisrv/wtv_img_converter.js | 2 +- 5 files changed, 182 insertions(+), 170 deletions(-) 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 11aecf4ec7f8f0727a0ed9f9e95f2be13d0f1d5a..797723bc5f38df586789b3a143c4cf8d5de2a5b2 100644 GIT binary patch literal 5465 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 literal 5740 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 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,