diff --git a/zefie_wtvp_minisrv/app.js b/zefie_wtvp_minisrv/app.js index 0a9be0e0..5d2008fa 100644 --- a/zefie_wtvp_minisrv/app.js +++ b/zefie_wtvp_minisrv/app.js @@ -1338,28 +1338,49 @@ async function sendToClient(socket, headers_obj, data = null) { delete headers_obj['minisrv-no-last-modified']; } - if (minisrv_config.config.decode_unsupported_images) { + if (minisrv_config.config.image_decoder && minisrv_config.config.image_decoder.enabled) { const contype_key = wtvshared.getCaseInsensitiveKey('content-type', headers_obj); if (contype_key) { - if (headers_obj[contype_key].toLowerCase() === "image/png" || - headers_obj[contype_key].toLowerCase() === "image/svg+xml" || - headers_obj[contype_key].toLowerCase() === "image/avif" || - headers_obj[contype_key].toLowerCase() === "image/tiff" || - headers_obj[contype_key].toLowerCase() === "image/webp") { + if (minisrv_config.config.image_decoder.image_formats && minisrv_config.config.image_decoder.image_formats.includes(headers_obj[contype_key].toLowerCase())) { const convertOpts = { - jpegQuality: minisrv_config.config.decode_unsupported_images_quality, + jpegQuality: minisrv_config.config.image_decoder.jpg_quality, type: 'ALF' }; + + 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; + const sourceData = Buffer.isBuffer(data) ? data : Buffer.from(data); try { const converted = await WTVImage.ImageToWebTV(sourceData, convertOpts); data = converted.data; content_length = data.length; - headers_obj[contype_key] = (converted.mime === 'image/jpeg') ? 'image/jpeg' : 'image/gif'; + var i=0; + while (content_length > minisrv_config.config.image_decoder.max_size && converted.mime === 'image/jpeg') { + // Image is too big, try to reduce quality + if (i < minisrv_config.config.image_decoder.max_quality_tries) { + convertOpts.jpegQuality -= minisrv_config.config.image_decoder.jpeg_interval; + var converted2 = await WTVImage.ImageToWebTV(sourceData, convertOpts); + data = converted2.data; + content_length = data.length; + i++; + } else { + break; + } + } + if (content_length > minisrv_config.config.image_decoder.max_size) { + headers_obj = { + "Status": `400 ${minisrv_config.config.service_name} ran into a technical problem. (Image too large)`, + "Content-type": "text/html" + } + data = ""; + } else { + headers_obj[contype_key] = (converted.mime === 'image/jpeg') ? 'image/jpeg' : 'image/gif'; + } } catch (e) { console.error("Error converting image for client:", e); headers_obj = { - "Status": `400 ${minisrv_config.config.service_name} ran into a technical problem. (Image not supported)`, + "Status": `400 ${minisrv_config.config.service_name} ran into a technical problem. (Image not supported by backend, it may be corrupt)`, "Content-type": "text/html" } data = ""; diff --git a/zefie_wtvp_minisrv/includes/ServiceVault/SharedROMCache/minisrv.gif b/zefie_wtvp_minisrv/includes/ServiceVault/SharedROMCache/minisrv.gif index 275e5c84..11aecf4e 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/ServiceVault/SharedROMCache/splash_minisrv.gif b/zefie_wtvp_minisrv/includes/ServiceVault/SharedROMCache/splash_minisrv.gif index a1e58b2d..748b41f2 100644 Binary files a/zefie_wtvp_minisrv/includes/ServiceVault/SharedROMCache/splash_minisrv.gif and b/zefie_wtvp_minisrv/includes/ServiceVault/SharedROMCache/splash_minisrv.gif differ diff --git a/zefie_wtvp_minisrv/includes/classes/WTVImage.js b/zefie_wtvp_minisrv/includes/classes/WTVImage.js index 17e5fb2e..502dd168 100644 --- a/zefie_wtvp_minisrv/includes/classes/WTVImage.js +++ b/zefie_wtvp_minisrv/includes/classes/WTVImage.js @@ -23,6 +23,8 @@ * * Decoder reverse-engineered from: * https://gist.github.com/PajamaFrix/399c0785c5bb3b1d80757e84a0c1d6ab + * + * TODO: Fix ALP Generation (decoding works but encoding does not yet produce correct ALP files) */ const sharp = require('sharp'); @@ -958,10 +960,23 @@ class WTVImage { * @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 + * @param {number} [opts.maxWidth] - maximum width to scale to before encoding + * @param {number} [opts.maxHeight] - maximum height to scale to before encoding * @returns {Promise<{ data: Buffer, mime: string }>} */ async ImageToWebTV(input, opts = {}) { - const 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 maxHeight = Number(opts.maxHeight) > 0 ? Number(opts.maxHeight) : null; + if (maxWidth || maxHeight) { + const resizeOpts = { fit: 'inside', withoutEnlargement: true }; + if (maxWidth) resizeOpts.width = maxWidth; + if (maxHeight) resizeOpts.height = maxHeight; + pngBuf = await sharp(pngBuf) + .resize(resizeOpts) + .png() + .toBuffer(); + } const meta = await sharp(pngBuf).metadata(); let usesAlpha = false; @@ -989,8 +1004,10 @@ class WTVImage { 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 + // If resizing was applied, the palette is no longer preserved and we must + // re-quantize the image before producing an Artemis GIF. + const forceRequantize = opts.forceRequantizePalette || maxWidth || maxHeight; + const data = forceRequantize ? await this.encodeArtemisGIF(pngBuf, opts) : await this.paletteImageToArtemisGIF(pngBuf, opts); return { data, mime: 'image/gif' }; diff --git a/zefie_wtvp_minisrv/includes/config.json b/zefie_wtvp_minisrv/includes/config.json index 9520c311..f33603b2 100644 --- a/zefie_wtvp_minisrv/includes/config.json +++ b/zefie_wtvp_minisrv/includes/config.json @@ -22,8 +22,6 @@ "cgi_enabled": false, // Disable CGI by default "php_enabled": false, // Disable PHP by default "php_binpath": "php-cgi", - "decode_unsupported_images": true, // Attempt to decode images WebTV doesn't support into JPG/ALF/GIF - "decode_unsupported_images_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. @@ -112,7 +110,23 @@ Each level of shenanigans includes the previous level (eg 5 will also disable filters like 4) See WTVShenanigans.js for more info. */ - "shenanigans": false + "shenanigans": false, + "image_decoder": { + "enabled": true, + "jpg_quality": 75, + "image_formats": [ + "image/png", + "image/svg+xml", + "image/avif", + "image/tiff", + "image/webp" + ], + "max_height": 0, + "max_width": 640, + "max_file_size": 524288, + "jpeg_interval": 5, // lower quality by this amount to try to lower filesize + "max_quality_tries": 5 // try to decode up to this many times, reducing quality each time, until the file is under the max_file_size. After this many tries, it will error out rather than sending an oversized file to the client. + } }, "services": { // service definitions @@ -218,7 +232,7 @@ "flags": "0x00000001", "privileged": true, "send_tellyscripts": true, // Best left untouched - "send_tellyscript_to_mame": false, + "send_tellyscript_to_mame": true, "dialin_number": 18006138199, "dns1ip": "10.0.0.50", "dns2ip": "8.8.8.8", diff --git a/zefie_wtvp_minisrv/wtv_img_converter.js b/zefie_wtvp_minisrv/wtv_img_converter.js new file mode 100644 index 00000000..e2e2336b --- /dev/null +++ b/zefie_wtvp_minisrv/wtv_img_converter.js @@ -0,0 +1,207 @@ +#!/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 WTVImage = require('./includes/classes/WTVImage'); + +// --------------------------------------------------------------------------- +// 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 === '--max-width') { + args.options.maxWidth = parseInt(argv[++i], 10); + } else if (a === '--max-height') { + args.options.maxHeight = 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 Image Converter +======================= +Usage: node wtv_img_converter.js [options] [output] + +Commands: + convert Convert an image 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: ALF) + --colors Palette size for full-color quantization (default: 256) + --quality JPEG quality when output is JPEG (default: 85) + --max-width Maximum width to scale input before encoding + --max-height Maximum height to scale input before encoding + --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 ImageBuf = fs.readFileSync(inputFile); + const { data, mime } = await WTVImage.ImageToWebTV(ImageBuf, { + type: opts.type || 'ALF', + colors: opts.colors || 256, + jpegQuality: opts.quality || 85, + maxWidth: opts.maxWidth, + maxHeight: opts.maxHeight + }); + + 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 ImageBuf = fs.readFileSync(inputFile); + const gifBuf = await WTVImage.ImageToGIF(ImageBuf, { + type: opts.type || 'ALF', + colors: opts.colors || 256 + }); + + const dest = resolveOutput(inputFile, '.gif', outputFile); + fs.writeFileSync(dest, gifBuf); + const type = WTVImage.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 = WTVImage.detect(gifBuf); + if (!type) { + console.error(`[decode] ${inputFile} does not contain an Artemis ALP/ALF block.`); + process.exit(1); + } + + const ImageBuf = await WTVImage.gifToPNG(gifBuf); + const dest = resolveOutput(inputFile, '.png', outputFile); + fs.writeFileSync(dest, ImageBuf); + console.log(`[decode] ${inputFile} (Artemis ${type}) → ${dest} (${ImageBuf.length} bytes)`); +} + +function cmdDetect(inputFile) { + const buf = fs.readFileSync(inputFile); + const type = WTVImage.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); + } +})(); diff --git a/zefie_wtvp_minisrv/wtv_png_converter.js b/zefie_wtvp_minisrv/wtv_png_converter.js index 989aad76..e2e2336b 100644 --- a/zefie_wtvp_minisrv/wtv_png_converter.js +++ b/zefie_wtvp_minisrv/wtv_png_converter.js @@ -41,6 +41,10 @@ function parseArgs(argv) { args.options.colors = parseInt(argv[++i], 10); } else if (a === '--quality') { args.options.quality = parseInt(argv[++i], 10); + } else if (a === '--max-width') { + args.options.maxWidth = parseInt(argv[++i], 10); + } else if (a === '--max-height') { + args.options.maxHeight = parseInt(argv[++i], 10); } else if (a === '--output' || a === '-o') { args.options.output = argv[++i]; } else if (a.startsWith('--')) { @@ -76,6 +80,8 @@ Options: --type Artemis variant for encoding/convert (default: ALF) --colors Palette size for full-color quantization (default: 256) --quality JPEG quality when output is JPEG (default: 85) + --max-width Maximum width to scale input before encoding + --max-height Maximum height to scale input before encoding --output, -o Output file path --help, -h Show this help @@ -106,9 +112,11 @@ 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', - colors: opts.colors || 256, - jpegQuality: opts.quality || 85 + type: opts.type || 'ALF', + colors: opts.colors || 256, + jpegQuality: opts.quality || 85, + maxWidth: opts.maxWidth, + maxHeight: opts.maxHeight }); const ext = mime === 'image/gif' ? '.gif' : '.jpg';