optimize images

This commit is contained in:
zefie
2026-04-26 22:47:21 -04:00
parent 2232beacb7
commit 23e06fc2c0
5 changed files with 182 additions and 170 deletions

View File

@@ -1001,7 +1001,17 @@ async function processURL(socket, request_headers, pc_services = false) {
request_headers.query = {}; request_headers.query = {};
if (request_headers.request_url) { 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]) { if (minisrv_config.services[service_name]) {
allow_double_slash = minisrv_config.services[service_name].allow_double_slash || false; allow_double_slash = minisrv_config.services[service_name].allow_double_slash || false;
enable_multi_query = minisrv_config.services[service_name].enable_multi_query || 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 // Add last modified if not a dynamic script
if (socket_sessions[socket.id]) { if (socket_sessions[socket.id]) {
if (socket_sessions[socket.id].request_headers) { if (socket_sessions[socket.id].request_headers) {
if (socket_sessions[socket.id].request_headers.query) { if (socket_sessions[socket.id].request_headers.query) {
if (socket_sessions[socket.id].request_headers.query.forceALF) { if (socket_sessions[socket.id].request_headers.query.type === "ALF" ||
imageArtemisType = '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) { 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_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.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); const sourceData = Buffer.isBuffer(data) ? data : Buffer.from(data);
try { try {
const converted = await WTVImage.ImageToWebTV(sourceData, convertOpts); const converted = await WTVImage.ImageToWebTV(sourceData, convertOpts, pngOpts);
data = converted.data; data = converted.data;
content_length = data.length; content_length = data.length;
var i=0; var i=0;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -489,6 +489,156 @@ class WTVImage {
return { rgba, width, height, type }; 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 * Encode an RGBA image (raw Buffer or sharp-compatible input) into a WebTV
* Artemis ALF GIF. * Artemis ALF GIF.
@@ -525,173 +675,13 @@ class WTVImage {
.toBuffer({ resolveWithObject: true }); .toBuffer({ resolveWithObject: true });
const { width, height } = info; 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; 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 finalIndices = Buffer.from(indices);
const fullAlpha = Buffer.from(alphaTable); const fullAlpha = Buffer.from(alphaTable);
if (bestZeroIdx >= 0 && bestZeroIdx !== transparentIdx) { if (bestZeroIdx >= 0 && bestZeroIdx !== transparentIdx) {
// Swap the transparent palette entry into the expected slot.
const tmpR = realPalette[transparentIdx * 3]; const tmpR = realPalette[transparentIdx * 3];
const tmpG = realPalette[transparentIdx * 3 + 1]; const tmpG = realPalette[transparentIdx * 3 + 1];
const tmpB = realPalette[transparentIdx * 3 + 2]; const tmpB = realPalette[transparentIdx * 3 + 2];
@@ -986,7 +976,7 @@ class WTVImage {
* @param {number} [opts.maxHeight] - maximum height to scale to before encoding * @param {number} [opts.maxHeight] - maximum height to scale to before encoding
* @returns {Promise<{ data: Buffer, mime: string }>} * @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); let pngBuf = Buffer.isBuffer(input) ? input : require('fs').readFileSync(input);
const maxWidth = Number(opts.maxWidth) > 0 ? Number(opts.maxWidth) : null; const maxWidth = Number(opts.maxWidth) > 0 ? Number(opts.maxWidth) : null;
const maxHeight = Number(opts.maxHeight) > 0 ? Number(opts.maxHeight) : null; const maxHeight = Number(opts.maxHeight) > 0 ? Number(opts.maxHeight) : null;
@@ -996,7 +986,7 @@ class WTVImage {
if (maxHeight) resizeOpts.height = maxHeight; if (maxHeight) resizeOpts.height = maxHeight;
pngBuf = await sharp(pngBuf) pngBuf = await sharp(pngBuf)
.resize(resizeOpts) .resize(resizeOpts)
.png() .png(pngopts)
.toBuffer(); .toBuffer();
} }
const meta = await sharp(pngBuf).metadata(); const meta = await sharp(pngBuf).metadata();

View File

@@ -117,6 +117,7 @@
"shenanigans": false, "shenanigans": false,
"image_decoder": { "image_decoder": {
"enabled": true, "enabled": true,
"gif_type": "ALP", // "ALP" or "ALF", see WTVImage.js for details.
"jpg_quality": 75, "jpg_quality": 75,
"image_formats": [ "image_formats": [
"image/png", "image/png",
@@ -125,6 +126,12 @@
"image/tiff", "image/tiff",
"image/webp" "image/webp"
], ],
"png_opts": {
"quality": 80,
"compressionLevel": 9,
"palette": true,
"effort": 10
},
"max_height": 2048, "max_height": 2048,
"max_width": 640, "max_width": 640,
"max_file_size": 524288, "max_file_size": 524288,

View File

@@ -112,7 +112,7 @@ function resolveOutput(inputFile, suggestedExt, override) {
async function cmdConvert(inputFile, outputFile, opts) { async function cmdConvert(inputFile, outputFile, opts) {
const ImageBuf = fs.readFileSync(inputFile); const ImageBuf = fs.readFileSync(inputFile);
const { data, mime } = await WTVImage.ImageToWebTV(ImageBuf, { const { data, mime } = await WTVImage.ImageToWebTV(ImageBuf, {
type: opts.type || 'ALF', type: opts.type || 'ALP',
colors: opts.colors || 256, colors: opts.colors || 256,
jpegQuality: opts.quality || 85, jpegQuality: opts.quality || 85,
maxWidth: opts.maxWidth, maxWidth: opts.maxWidth,