optimize images
This commit is contained in:
@@ -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;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.3 KiB |
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user