From 806444d2a96f9165a21e5aa9339c9077df678ac3 Mon Sep 17 00:00:00 2001 From: vonmillhausen Date: Tue, 14 May 2024 20:20:57 +0100 Subject: [PATCH] Tool updates... MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This was just supposed to be a new feature for the generic image tool (dithering support), but while working on that things kind of snowballed 😅 * Added dithering support to the generic image tool and the boot logo changer, when converting images to RGB565 format. This uses a Bayer 8x8 matrix, and the overall "strength" of the dither can be controlled - it defaults to what I feel is a sane value. Dithering can help reduce banding effects due to the low colour depth in RGB565. * Made "fix" scaling mode more flexible in the generic image tool - now there's checkboxes beside the width and height dimensions - if you un-check one, the other dimension will be calculated automatically to keep the input's aspect ratio intact * Improved downscaling quality in the generic image tool. While working on the dithering feature, I discovered the previous "gaussian resampling" downscaling method I was using introduced distortion in certain situations. I had a lot of fun playing around with possible replacements (I tried 10 new downscaling functions!), and finally settled on a hybrid function that mixes powers-of-two downscaling (with some custom mipmap style cross-blending) with Hermite interpolation. This new method is reasonably quick, gives clean results with no great distortion, and works well with alpha channels (doesn't introduce any dark fringing) * Generic image tool now shows the dimensions of the output image * Added a max width on the main tool page bodies, so that they don't get so wide on full-screen desktop browsers * Fixed a few edge-case logic bugs here and there (e.g., when upscaling only a single dimensions with the generic image tool, or places where I thought I was copying objects, but was only creating references to them, etc.) * Switched from using "var" declarations across all tool codebases to "let" or "const" instead * Switched out the SVG alert icons to just use emoji instead * Various other nips and tucks (fixed up my arbitrary 80-column comment wrapping on the tools that had previously just been eye-balled, fixed a few comment typos, etc.) --- tools/biosCRC32Patcher.htm | 10 +- tools/bootLogoChanger.htm | 152 +++++-- tools/buttonMappingChanger.htm | 153 ++++--- tools/firmwareVersionChecker.htm | 8 +- tools/genericImageTool.htm | 400 +++++++++-------- tools/saveStateTool.htm | 283 ++++++------ tools/tools.css | 6 + tools/tools.js | 731 +++++++++++++++++++------------ 8 files changed, 1044 insertions(+), 699 deletions(-) diff --git a/tools/biosCRC32Patcher.htm b/tools/biosCRC32Patcher.htm index d5830c2..94757da 100644 --- a/tools/biosCRC32Patcher.htm +++ b/tools/biosCRC32Patcher.htm @@ -25,7 +25,7 @@
-

CC0: public domain. Version 1.1, 20230626.1

+

CC0: public domain. Version 1.2, 20240514.1

diff --git a/tools/bootLogoChanger.htm b/tools/bootLogoChanger.htm index 1e73a81..576d945 100644 --- a/tools/bootLogoChanger.htm +++ b/tools/bootLogoChanger.htm @@ -24,15 +24,17 @@
-

CC0: public domain. Version 1.5, 20231019.1

+

CC0: public domain. Version 1.6, 20240514.1

diff --git a/tools/buttonMappingChanger.htm b/tools/buttonMappingChanger.htm index 28a09b1..e267bb4 100644 --- a/tools/buttonMappingChanger.htm +++ b/tools/buttonMappingChanger.htm @@ -26,11 +26,11 @@
-

CC0: public domain. Version 1.5, 20231019.1

+

CC0: public domain. Version 1.6, 20240514.1

\ No newline at end of file diff --git a/tools/firmwareVersionChecker.htm b/tools/firmwareVersionChecker.htm index 4c52d2c..e39c854 100644 --- a/tools/firmwareVersionChecker.htm +++ b/tools/firmwareVersionChecker.htm @@ -24,13 +24,13 @@
-

CC0: public domain. Version 1.2, 20231019.1

+

CC0: public domain. Version 1.3, 20240514.1

diff --git a/tools/genericImageTool.htm b/tools/genericImageTool.htm index 15f7973..a9d7f11 100644 --- a/tools/genericImageTool.htm +++ b/tools/genericImageTool.htm @@ -15,8 +15,8 @@

Step 1: Select Operating Mode

This tool can operate in two main modes - you can use it to convert from an SF2000 format to RGB888/RGBA, or you can use it to convert to an SF2000 format from RGB888/RGBA. Choose the mode you want, and follow the next instruction that appears.

-
- +
+
@@ -25,24 +25,28 @@ // Global Variables // ================ - var sf2000ImageData; // Holds binary data for images coming from the SF2000 - var userImageData; // Holds the original data of the PNG/JPEG coming from the user - var previewCanvasData; // Holds the binary data used for drawing to our preview canvases - var outputImageWidth = 320; // The width the user wants to interpret the image from the SF2000 as, default to 320px - var outputImageHeight = 240; // The height the user wants to interpret the image from the SF2000 as, default to 240px - var imageFormat = "RGB565"; // The format the user wants to interpret the image coming from the SF2000 as, default to RGB565 - var fileName; // Holds the name of the user-selected file - var userScaleMode = "scale"; // The type of scaling the user wants for their image, default to preset scales - var userScaleFactor = "1x"; // Holds the currently selected scaling factor, defaults to 1x - var userFitWidth = 320; // Holds the current fit-to width, default to 320px - var userFitHeight = 240; // Holds the current fit-to height, default to 240px - var userFilterType = "Nearest Neighbour"; // Holds the currently selected filter type, defaulting to Nearest Neighbour + let sf2000ImageData; // Holds binary data for images coming from the SF2000 + let userImageData; // Holds the original data of the PNG/JPEG coming from the user + let previewCanvasData; // Holds the binary data used for drawing to our preview canvases + let outputImageWidth = 320; // The width the user wants to interpret the image from the SF2000 as, default to 320px + let outputImageHeight = 240; // The height the user wants to interpret the image from the SF2000 as, default to 240px + let fileName; // Holds the name of the user-selected file + let imageFormat = "RGB565"; // The format the user wants to interpret the image coming from the SF2000 as, default to RGB565 + let ditherEnabled = false; // Holds whether the user wants to enable dithering for RGB565 mode or not + let ditherStrength = 0.2; // Holds the selected dither strength value - 0.2 seems to be a good general balance + let userScaleMode = "scale"; // The type of scaling the user wants for their image, default to preset scales + let userScaleFactor = "1x"; // Holds the currently selected scaling factor, defaults to 1x + let fitToWidthEnabled = true; // Holds whether the "fit to width" checkbox is ticked or not + let userFitWidth = 320; // Holds the current fit-to width, default to 320px + let fitToHeightEnabled = true; // Holds whether the "fit to width" checkbox is ticked or not + let userFitHeight = 240; // Holds the current fit-to height, default to 240px + let userFilterType = "Nearest Neighbour"; // Holds the currently selected filter type, defaulting to Nearest Neighbour // When the tool loads, add some event watchers for when the Step 1 radio // buttons change; and depending on which mode the user selects, begin // rewriting the rest of the page... - var modes = document.getElementsByName("toolMode"); - for (var i = 0; i < modes.length; i++) { + const modes = document.getElementsByName("toolMode"); + for (let i = 0; i < modes.length; i++) { modes[i].addEventListener("change", function() { // Clear out any HTML that might already exist after Step 1, just so @@ -69,10 +73,10 @@ // Create the new section, add our heading and our instruction // paragraph... - var html = "

Step 2: Select SF2000 Image File

Select the SF2000 image file you want to convert from. If you need help choosing the image you want to convert, you can find a reference to all of the used UI images in my overview of the SF2000 here.

"; + let html = "

Step 2: Select SF2000 Image File

Select the SF2000 image file you want to convert from. If you need help choosing the image you want to convert, you can find a reference to all of the used UI images in my overview of the SF2000 here.

"; // Add our file chooser... - html += "
"; + html += "
"; // Close off our section... html += "
"; @@ -83,7 +87,7 @@ document.getElementById("steps").insertAdjacentHTML("beforeend", html); // Attach our event handler to our new file input control... - var sf2000ImageInput = document.getElementById("inputImage"); + const sf2000ImageInput = document.getElementById("inputImage"); sf2000ImageInput.addEventListener("change", function() { // The user has chosen a new file; it should be a binary blob we can @@ -92,22 +96,22 @@ // Now let's check to make sure the data URI indicates // "application/octet-stream"... - var frImage = new FileReader(); + const frImage = new FileReader(); fileName = event.target.files[0].name; frImage.readAsDataURL(event.target.files[0]); frImage.onload = function(event) { - var fileData = event.target.result; - var dataType = fileData.substring(5, fileData.indexOf(";")); + const fileData = event.target.result; + const dataType = fileData.substring(5, fileData.indexOf(";")); if (dataType === "application/octet-stream") { // The user selected a file that appears to contain binary data; // it's a good candidate for image interpretation so! Extract the // binary data, store it in our sf2000ImageData global, and start // setting up Step 3... - var base64Data = fileData.substring(fileData.indexOf(",") + 1); - var binaryData = atob(base64Data); + const base64Data = fileData.substring(fileData.indexOf(",") + 1); + const binaryData = atob(base64Data); sf2000ImageData = new Uint8Array(binaryData.length); - for (var i = 0; i < binaryData.length; i++) { + for (let i = 0; i < binaryData.length; i++) { sf2000ImageData[i] = binaryData.charCodeAt(i); } @@ -137,10 +141,10 @@ function setupStepTwo_To() { // Start our new section, add our header and our instructions... - var html = "

Step 2: Select RGB888/RGBA Image File

Select the image file you want to convert to an SF2000 image format. You can select either a PNG or JPEG format image. Transparency is respected for PNGs, but only if outputting to the BGRA output format.

"; + let html = "

Step 2: Select RGB888/RGBA Image File

Select the image file you want to convert to an SF2000 image format. You can select either a PNG or JPEG format image. Transparency is respected for PNGs, but only if outputting to the BGRA output format.

"; // Add our file chooser... - html += "
"; + html += "
"; // Close our section... html += "
"; @@ -151,17 +155,16 @@ document.getElementById("steps").insertAdjacentHTML("beforeend", html); // Attach our event handler to our new file input control... - var userImageInput = document.getElementById("inputImage"); + const userImageInput = document.getElementById("inputImage"); userImageInput.addEventListener("change", function() { // The user has chosen a new file; it should be either a PNG or JPEG - // image for converting to an SF2000 binary image format... - - // First let's clear any old messages... + // image for converting to an SF2000 binary image format. First let's + // clear any old messages... document.getElementById("step2Messages").innerHTML = ""; // Now let's read in the file... - var frImage = new FileReader(); + const frImage = new FileReader(); fileName = event.target.files[0].name; frImage.readAsDataURL(event.target.files[0]); frImage.onload = function(event) { @@ -173,7 +176,6 @@ // Whoops! Doesn't look like the user provided a PNG or a JPEG! setMessage("error", "step2Messages", "ERROR: The selected file does not appear to be either a PNG or JPEG image; please make sure you're selecting an appropriate image file."); return; - } // Now we're going to load the file's data into an Image object, so @@ -335,15 +337,15 @@ // Create the new section, add our heading and our instruction // paragraph... - var html = "

Step 3: Set Data Interpretation Options

As the image files on the SF2000 are raw binary blobs, there's no information stored in them to say what their width or height is, what format the pixel information is stored in, etc.. Therefore, it is up to you to specify those details yourself, which you can do using the options below.

A preview of the image will appear below the options, reflecting the current options; the preview has a white dash outline so you can better judge where the bounds of transparent images are. Below the preview image is a \"Download\" button you can use to download the image as a PNG.

Depending on the size of the file you selected, this tool may recognise it as a \"known\" file from the SF2000, and if so the options below will have been set automatically for you. If not, or if the options are automatically set incorrectly, here's a brief description of what each option does:

Image Format:
Specifies the data format for each pixel in the image. There are two image formats currently used by the SF2000 - RGB565 (used for most of the \"basic\" UI elements, including main backgrounds, etc.), and BGRA (used for anything that requires transparency, such as the system logos on the main menu, etc.).
Width:
Specifies the width of the image in pixels.
Height:
Specifies the height of the image in pixels.
"; + let html = "

Step 3: Set Data Interpretation Options

As the image files on the SF2000 are raw binary blobs, there's no information stored in them to say what their width or height is, what format the pixel information is stored in, etc.. Therefore, it is up to you to specify those details yourself, which you can do using the options below.

A preview of the image will appear below the options, reflecting the current options; the preview has a white dash outline so you can better judge where the bounds of transparent images are. Below the preview image is a \"Download\" button you can use to download the image as a PNG.

Depending on the size of the file you selected, this tool may recognise it as a \"known\" file from the SF2000, and if so the options below will have been set automatically for you. If not, or if the options are automatically set incorrectly, here's a brief description of what each option does:

Image Format:
Specifies the data format for each pixel in the image. There are two image formats currently used by the SF2000 - RGB565 (used for most of the \"basic\" UI elements, including main backgrounds, etc.), and BGRA (used for anything that requires transparency, such as the system logos on the main menu, etc.).
Width:
Specifies the width of the image in pixels.
Height:
Specifies the height of the image in pixels.
"; // Next, let's add our image controls; a select list for the image // format, and number inputs for the interpreted width and height... - html += "
"; - html += "
"; - html += "
"; - html += "
"; - html += "
"; + html += "
"; + html += "
"; + html += "
"; + html += "
"; + html += "
"; // Next, we'll add the image preview... html += "
"; @@ -359,18 +361,12 @@ document.getElementById("steps").insertAdjacentHTML("beforeend", "
"); document.getElementById("steps").insertAdjacentHTML("beforeend", html); - // Now, add our interactivity by attaching our event handlers; first up, - // our format selector... - var ifField = document.getElementById("imageFormat"); - ifField.addEventListener("change", function() { - // Set our global imageFormat to the new value, and update our - // preview... - imageFormat = this.value; - convertFromSF2000AndRender(); - }); - - // Next our width and height fields... - var whFields = [document.getElementById("outputImageWidth"), document.getElementById("outputImageHeight")]; + // Now, attach some event handlers to our form controls; these will + // handle some of the logic for how I want the controls to behave. First + // up, our width and height fields - I have some limitations on the + // types of things I want typed in them, and how I want them to be + // changed... + const whFields = [document.getElementById("outputImageWidth"), document.getElementById("outputImageHeight")]; whFields.forEach(field => { // When a key is pressed, only allow the events through under certain @@ -390,25 +386,39 @@ if (this.value === "" || this.value === "0") { this.value = "1"; } - if (this.id == "outputImageWidth") { - outputImageWidth = this.value; - } - else { - outputImageHeight = this.value; - } - convertFromSF2000AndRender(); }); }); - // And attach the event handler for the download button... - var downloadButton = document.getElementById("sf2000Download"); + // Next, attach the event handler for the download button... + const downloadButton = document.getElementById("sf2000Download"); downloadButton.addEventListener("click", function() { - var canvas = document.getElementById("processFilePreview"); + const canvas = document.getElementById("processFilePreview"); canvas.toBlob(function(blob) { downloadToBrowser(blob, "image/png", fileName + ".png"); }); }); + // That's the end of the specific UI event handlers; the final handler + // we'll set up is a general event handler for the entire form itself. + // Any time *any* of the controls in the form change, that event will + // bubble-up and trigger this handler, where we'll get the current state + // of the form's controls, and use it to re-convert and re-render the + // image using the current settings... + const fromSF2000Form = document.getElementById("fromSF2000ImageSettings"); + fromSF2000Form.addEventListener("change", function() { + + // Get the state of all of the form's controls... + const formData = Object.fromEntries(new FormData(fromSF2000Form)); + + // Set our general globals from the current form state... + imageFormat = formData.imageFormat; + outputImageWidth = formData.outputImageWidth; + outputImageHeight = formData.outputImageHeight; + + // And re-convert and re-render based on those globals... + convertFromSF2000AndRender(); + }); + // We're nearly ready to wrap up Step 3; all that's left is to perform // our initial conversion and rendering... convertFromSF2000AndRender(); @@ -423,7 +433,7 @@ // from a known image, we already have our width and height this time, // and so we can just get on to rendering the HTML. Create our new // section, add its heading and instruction paragraphs... - html = "

Step 3: Set Conversion Options

The options below control how your image is converted for use on the SF2000. A preview of the image will appear below the options, reflecting their current settings; the preview has a white dash outline so you can better judge where the bounds of transparent images are. Below the preview image is a \"Download\" button you can use to download the image as binary blob for the SF2000. If you name your input image like name.extension.extension (e.g., c1eac.pal.png), then the download will automatically be named like name.extension (e.g., c1eac.pal); this may help to speed up your workflow.

Here's a brief description of what each option does:

Image Format:
Specifies the data format for the output image. There are two image formats currently used by the SF2000 - RGB565 (used for most of the \"basic\" UI elements, including main backgrounds, etc.), and BGRA (used for anything that requires transparency, such as the system logos on the main menu, etc.). If you're not sure which format to choose, refer to my list of images used by the SF2000.
Scaling:
One of two available scaling modes for your input image; the \"Scaling\" option lets you scale your image maintaining aspect ratio amongst several common sizes (e.g., 1x scaling to maintain the input image size, 2x scaling to double it, etc.)
Fit to:
The other available scaling mode; this will scale your image to the specified width and height, without maintaining aspect ratio. This will allow you to scale an image to any dimension you want, but it'll be up to you to do your own aspect ratio calculations.
Filter Type:
When scaling an image by anything other than 1x, specifies the type of image filtering to use. \"Nearest Neighbour\" will give sharp pixel scaling but only at integer upscales; any other scale will appear aliased. \"Bilinear\" will give a fuzzier scale, but it works better for non-integer scale factors and for downscaling.
"; + let html = "

Step 3: Set Conversion Options

The options below control how your image is converted for use on the SF2000. A preview of the image will appear below the options, reflecting their current settings; the preview has a white dash outline so you can better judge where the bounds of transparent images are. Below the preview image is a \"Download\" button you can use to download the image as binary blob for the SF2000. If you name your input image like name.extension.extension (e.g., c1eac.pal.png), then the download will automatically be named like name.extension (e.g., c1eac.pal); this may help to speed up your workflow.

Here's a brief description of what each option does:

Image Format:
Specifies the data format for the output image. There are two image formats currently used by the SF2000 - RGB565 (used for most of the \"basic\" UI elements, including main backgrounds, etc.), and BGRA (used for anything that requires transparency, such as the system logos on the main menu, etc.). If you're not sure which format to choose, refer to my list of images used by the SF2000.
Scaling:
One of two available scaling modes for your input image; the \"Scaling\" option lets you scale your image maintaining aspect ratio amongst several common sizes (e.g., 1x scaling to maintain the input image size, 2x scaling to double it, etc.)
Fit to:
The other available scaling mode; this will scale your image to the specified width and height, without maintaining aspect ratio. This will allow you to scale an image to any dimension you want, but it'll be up to you to do your own aspect ratio calculations.
Filter Type:
When scaling an image by anything other than 1x, specifies the type of image filtering to use. \"Nearest Neighbour\" will give sharp pixel scaling but only at integer upscales; any other scale will appear aliased. \"Bilinear\" will give a fuzzier scale, but it works better for non-integer scale factors and for downscaling.
"; // Now let's add our image controls; there's a few of them! We have a // select box for choosing the image format, we have two different @@ -431,21 +441,24 @@ // scales, and one for user-defined output size), and we have a select // box for choosing nearest-neighbour or hybrid gaussian/bilinear // image filtering... - html += "
"; - // Image format... - html += "
"; + html += "
"; + // Image format and dithering options... + html += "
"; + html += "
"; + html += "
"; + html += "
"; // Scaling options... html += "
"; - html += "
"; + html += "
"; html += " OR "; - html += "
and
"; + html += "
and
"; html += "
"; // Filter type... - html += "
"; - html += "
"; + html += "
"; + html += ""; // Next we'll add our image preview... - html += "
"; + html += "

"; // ... and our Download button... html += "
"; @@ -458,76 +471,80 @@ document.getElementById("steps").insertAdjacentHTML("beforeend", "
"); document.getElementById("steps").insertAdjacentHTML("beforeend", html); - // Now we'll attach event listeners for all of the interactive elements - // in this step. We'll start with our image format select box... - var ifField = document.getElementById("imageFormat"); - ifField.addEventListener("change", function() { + // Now, attach some event handlers to our form controls; these will + // handle some of the logic for how I want the controls to behave. + // First, let's enable/disable dithering options depending on whether or + // not image format is set to RGB565... + const formatSelect = document.getElementById("imageFormat"); + formatSelect.addEventListener("change", function() { + switch (this.value){ + case "RGB565": + document.getElementById("ditherEnabled").removeAttribute("disabled"); + document.getElementById("ditherStrength").removeAttribute("disabled"); + break; - // Update our global variable with the new value, convert the user's - // image using the new format and render a preview... - imageFormat = this.value; - let newWH = calculateNewSize(userImageData); - convertImageToSF2000(scaleImage(userImageData, newWH.width, newWH.height, userFilterType)); - convertFromSF2000AndRender(); + case "BGRA": + document.getElementById("ditherEnabled").setAttribute("disabled", ""); + document.getElementById("ditherStrength").setAttribute("disabled", ""); + break; + } }); - // The scaling mode radio buttons; these let the user choose between - // fixed-ratio scaling, or fixed width/height scaling... - var modes = document.getElementsByName("scaleMode"); - for (var i = 0; i < modes.length; i++) { + // Now let's do the scaling mode radio buttons; these let the user + // choose between fixed-ratio scaling, or fixed width/height scaling... + const modes = document.getElementsByName("scaleMode"); + for (let i = 0; i < modes.length; i++) { modes[i].addEventListener("change", function() { - // Update our global variable with the new selection... - userScaleMode = this.value; - // Check what they chose... - if (userScaleMode == "scale") { - + if (this.value == "scale") { // They chose fixed-ratio scaling, so enable the ratio selector, - // and disable the fixed-width/height number inputs... + // and disable the fixed-width/height controls... document.getElementById("scaleFactor").removeAttribute("disabled"); + document.getElementById("fitToWidthEnabled").setAttribute("disabled", ""); document.getElementById("userFitWidth").setAttribute("disabled", ""); + document.getElementById("fitToHeightEnabled").setAttribute("disabled", ""); document.getElementById("userFitHeight").setAttribute("disabled", ""); } - else if (userScaleMode == "fit") { - + else if (this.value == "fit") { // They chose fixed width/height scaling, so enable our // width/height number inputs, and disable the ratio-scaling // selection box... + document.getElementById("fitToWidthEnabled").removeAttribute("disabled"); document.getElementById("userFitWidth").removeAttribute("disabled"); + document.getElementById("fitToHeightEnabled").removeAttribute("disabled"); document.getElementById("userFitHeight").removeAttribute("disabled"); document.getElementById("scaleFactor").setAttribute("disabled", ""); } - - // We'll also want to re-convert the user's image data and render - // a new preview... - let newWH = calculateNewSize(userImageData); - convertImageToSF2000(scaleImage(userImageData, newWH.width, newWH.height, userFilterType)); - convertFromSF2000AndRender(); }); } - // The fixed-ratio scaling selector... - var sField = document.getElementById("scaleFactor"); - sField.addEventListener("change", function() { - - // Update our global variable with the newly chosen scaling factor, - // and then re-convert the user's image data and render a new - // preview... - userScaleFactor = this.value; - let newWH = calculateNewSize(userImageData); - convertImageToSF2000(scaleImage(userImageData, newWH.width, newWH.height, userFilterType)); - convertFromSF2000AndRender(); + // Next, the fixed-width/height checkboxes; these have a little twist + // whereby we don't want to allow them both to be unticked at the same + // time - so we catch click events, and ignore them if they would cause + // both checkboxes to be unticked... + const widthCheckbox = document.getElementById("fitToWidthEnabled"); + widthCheckbox.addEventListener("click", function() { + const heightCheckbox = document.getElementById("fitToHeightEnabled"); + if (heightCheckbox.checked == false) { + event.preventDefault(); + } + }); + const heightCheckbox = document.getElementById("fitToHeightEnabled"); + heightCheckbox.addEventListener("click", function() { + const widthCheckbox = document.getElementById("fitToWidthEnabled"); + if (widthCheckbox.checked == false) { + event.preventDefault(); + } }); - // And the fixed-width/height number inputs... - var whFields = [document.getElementById("userFitWidth"), document.getElementById("userFitHeight")]; + // And now the fixed-width/height number inputs... + const whFields = [document.getElementById("userFitWidth"), document.getElementById("userFitHeight")]; whFields.forEach(field => { // These get two event listeners; the first is designed to prevent the // user from entering anything other than positive integers... field.addEventListener("keydown", function(event) { - // Only allow integer entry and things like arrow keys, etc! if (event.key.length > 1 || /\d/.test(event.key)) { return; @@ -535,95 +552,129 @@ event.preventDefault(); }); - // ... and this updates our global variables, re-converts the user's - // image data and renders a new preview image when the numbers are - // changed... + // ... and the second catches when if the value is blank or set to 0, + // and if so sets it to 1 instead... field.addEventListener("change", function() { - if (this.value === "" || this.value === "0") { this.value = "1"; } - if (this.id == "userFitWidth") { - userFitWidth = this.value; - } - else { - userFitHeight = this.value; - } - let newWH = calculateNewSize(userImageData); - convertImageToSF2000(scaleImage(userImageData, newWH.width, newWH.height, userFilterType)); - convertFromSF2000AndRender(); }); }); - // Next, the event listener for our filter type selection... - var ftField = document.getElementById("userFilterType"); - ftField.addEventListener("change", function() { - - // Update our global variable with the new filter type, re-convert - // the user's image data, and update our preview... - userFilterType = this.value; - let newWH = calculateNewSize(userImageData); - convertImageToSF2000(scaleImage(userImageData, newWH.width, newWH.height, userFilterType)); - convertFromSF2000AndRender(); - }); - // And last but not least, our Download button... - var dButton = document.getElementById("userDownload"); + const dButton = document.getElementById("userDownload"); dButton.addEventListener("click", function() { // For the file-name we're going to do something semi-fancy; if the // user's original image file name had *two* extensions (e.g., - // c1eac.pal.png), then we'll strip off the second extension and - // just save the file with the remainder of the name (e.g., - // c1eac.pal) - this will speed up their workflow if their source - // image came from a design tool that exports images named after - // layers/artboards. Otherwise, we'll just use the name of the - // file they provided with ".bin" appended to the end... - var downloadName = fileName + ".bin"; - var match = fileName.match(/(.+)\.([^.]+)\.([^.]+)/); + // c1eac.pal.png), then we'll strip off the second extension and just + // save the file with the remainder of the name (e.g., c1eac.pal) - + // this will speed up their workflow if their source image came from a + // design tool that exports images named after layers/art-boards. + // Otherwise, we'll just use the name of the file they provided with + // ".bin" appended to the end... + let downloadName = fileName + ".bin"; + const match = fileName.match(/(.+)\.([^.]+)\.([^.]+)/); if (match) { - // It's got a double extension - so let's just strip off the - // second one... + // It's got a double extension - so let's just strip off the second + // one... downloadName = match[1] + "." + match[2]; } downloadToBrowser(sf2000ImageData, "application/octet-stream", downloadName); }); + // Great, that's all the UI handling done. The final event we'll set up + // is on the overall control strip itself, to watch for changes to any + // of the controls. When any of them change, this event will trigger, + // and we'll get the new control state and use their current values to + // render an updated image... + const toSF2000Form = document.getElementById("toSF2000ImageSettings"); + toSF2000Form.addEventListener("change", function() { + + // Get the state of all of our form controls... + const formData = Object.fromEntries(new FormData(toSF2000Form)); + + // Set our general globals from the current form state... + imageFormat = formData.imageFormat; + if (formData.ditherEnabled !== undefined) {ditherEnabled = true;} + else if (imageFormat == "RGB565") {ditherEnabled = false;} + ditherEnabled = formData.ditherEnabled ? true : ditherEnabled; + ditherStrength = formData.ditherStrength? parseFloat(formData.ditherStrength) : ditherStrength; + userScaleMode = formData.scaleMode; + userScaleFactor = formData.scaleFactor; + if (formData.fitToWidthEnabled !== undefined) {fitToWidthEnabled = true;} + else if (userScaleMode == "fit") {fitToWidthEnabled = false;} + if (formData.fitToHeightEnabled !== undefined) {fitToHeightEnabled = true;} + else if (userScaleMode == "fit") {fitToHeightEnabled = false;} + userFitWidth = formData.userFitWidth ? formData.userFitWidth : userFitWidth; + userFitHeight = formData.userFitHeight ? formData.userFitHeight : userFitHeight; + userFilterType = formData.userFilterType; + + // Finally render an updated preview... + calculateOutputDimensions(); + convertImageToSF2000(scaleImage(userImageData, outputImageWidth, outputImageHeight, userFilterType), ditherEnabled, ditherStrength); + convertFromSF2000AndRender(); + }); + // The last thing to do here in Step 3 is to perform our initial // image data conversion and preview rendering... so let's do it! - let newWH = calculateNewSize(userImageData); - convertImageToSF2000(scaleImage(userImageData, newWH.width, newWH.height, userFilterType)); + calculateOutputDimensions(); + convertImageToSF2000(scaleImage(userImageData, outputImageWidth, outputImageHeight, userFilterType), ditherEnabled, ditherStrength); convertFromSF2000AndRender(); } - // This function takes in an ImageData object, and given our global - // variables for the image conversion options, returns an object - // containing the new width and height of the image based on those - // options... - function calculateNewSize(imageData) { - var newWidth = userFitWidth; - var newHeight = userFitHeight; - if (userScaleMode == "scale") { - newWidth = Math.round(imageData.width * Number(userScaleFactor.slice(0, -1))); - newHeight = Math.round(imageData.height * Number(userScaleFactor.slice(0, -1))); + // This function takes our userImageData global, and given our other + // global variables for the image conversion options, sets + // outputImageWidth and outputImageHeight based on calculated dimensions + // from the options... + function calculateOutputDimensions() { + + // Default to just being the same size as the original image... + outputImageWidth = userImageData.width; + outputImageHeight = userImageData.height; + + // Check to see which scaling mode the user has selected... + switch (userScaleMode){ + + case "scale": + // They've selected "scale" mode! So take the value from the + // selection box, strip off the "x" character at the end, and use it + // as a multiplier for the original image dimensions, rounding to + // the nearest whole number... + outputImageWidth = Math.round(outputImageWidth * Number(userScaleFactor.slice(0, -1))); + outputImageHeight = Math.round(outputImageHeight * Number(userScaleFactor.slice(0, -1))); + break; + + case "fit": + // They've selected "fit to width/height" mode! This is a little + // more involved than "scale", as the final width/height will depend + // on which combination of the width/height fitting checkboxes are + // currently checked... + if (fitToWidthEnabled){ + outputImageWidth = Number(userFitWidth); + if (fitToHeightEnabled) { + outputImageHeight = Number(userFitHeight); + } + else { + outputImageHeight = Math.round(userImageData.height * (outputImageWidth / userImageData.width)); + } + } + else if (fitToHeightEnabled){ + outputImageHeight = Number(userFitHeight); + outputImageWidth = Math.round(userImageData.width * (outputImageHeight / userImageData.height)); + } + break; } - - // Update the global variables used for rendering SF2000 image data with - // our new output width and height... - outputImageWidth = newWidth; - outputImageHeight = newHeight; - - return {width: newWidth, height: newHeight}; } // This function takes in an ImageData object, and uses our standard // library conversion functions to convert it to either RGB565 or BGRA // format, and sets the sf2000ImageData global to the resulting // Uint8Array object... - function convertImageToSF2000(sourceData) { + function convertImageToSF2000(sourceData, dither, ditherStrength) { if (imageFormat == "RGB565") { - sf2000ImageData = imageDataToRgb565(sourceData); + sf2000ImageData = imageDataToRgb565(sourceData, dither, ditherStrength); } else if (imageFormat == "BGRA") { sf2000ImageData = imageDataToBgra(sourceData); @@ -641,15 +692,20 @@ else if (imageFormat == "BGRA") { previewCanvasData = bgraToImageData(sf2000ImageData, outputImageWidth, outputImageHeight); } - var canvas = document.getElementById("processFilePreview"); - var context = canvas.getContext("2d"); + const canvas = document.getElementById("processFilePreview"); + const context = canvas.getContext("2d"); canvas.width = previewCanvasData.width; canvas.height = previewCanvasData.height; context.clearRect(0, 0, canvas.width, canvas.height); context.putImageData(previewCanvasData, 0, 0); + + // Just as a utility, let's also show the output image's width and + // height; we'll also set this as a title on the canvas... + document.getElementById("previewDimensions").innerText = previewCanvasData.width + " x " + previewCanvasData.height; + canvas.setAttribute("title", previewCanvasData.width + " x " + previewCanvasData.height); }
-

CC0: public domain. Version 1.1, 20230626.1

+

CC0: public domain. Version 1.2, 20240514.1

diff --git a/tools/saveStateTool.htm b/tools/saveStateTool.htm index fe048c8..75e3f71 100644 --- a/tools/saveStateTool.htm +++ b/tools/saveStateTool.htm @@ -29,14 +29,14 @@ // Global Variables // ================ - var previewCanvasData; // Binary data used for drawing to our preview canvas - var saveFileName; // Name of the save state file chosen by the user - var downloadName; // Name we're going to give any download being sent to the user - var saveData; // Binary data of a save state file - var thumbData; // Binary data of a save state thumbnail - var thumbWidth; // Integer width of a save state thumbnail - var thumbHeight; // Integer height of a save state thumbnail - var romFileGood; // Boolean indicating if a chosen save state file appears to be binary or not + let previewCanvasData; // Binary data used for drawing to our preview canvas + let saveFileName; // Name of the save state file chosen by the user + let downloadName; // Name we're going to give any download being sent to the user + let saveData; // Binary data of a save state file + let thumbData; // Binary data of a save state thumbnail + let thumbWidth; // Integer width of a save state thumbnail + let thumbHeight; // Integer height of a save state thumbnail + let romFileGood; // Boolean indicating if a chosen save state file appears to be binary or not // Display a note about platform compatibility for save states... setMessage("warning", "platformNote", "NOTE: The SF2000 is uses a MIPS processor; as a result, save states created on the SF2000 will likely only ever work on other MIPS devices, and will likely never work on any other kind of device (e.g., PCs, Android devices, Rockchip devices, ARM devices, etc.).") @@ -47,8 +47,8 @@ // When the tool loads, add some event watchers for when the Step 1 radio // buttons change; and depending on which mode the user selects, begin // rewriting the rest of the page... - var modes = document.getElementsByName("toolMode"); - for (var i = 0; i < modes.length; i++) { + const modes = document.getElementsByName("toolMode"); + for (let i = 0; i < modes.length; i++) { modes[i].addEventListener("change", function() { // Clear out any HTML that might already exist after Step 1, just so @@ -68,13 +68,14 @@ }); } - // This function sets up the HTML for "Convert from SF2000 > Step 2", selecting - // a save state file from the SF2000's microSD card that the user wants to - // convert to raw save state data and/or a thumbnail... + // This function sets up the HTML for "Convert from SF2000 > Step 2", + // selecting a save state file from the SF2000's microSD card that the + // user wants to convert to raw save state data and/or a thumbnail... function setupStepTwo_From() { - // Create the new section, add our heading and our instruction paragraph... - var html = "

Step 2: Select SF2000 Save State File

Select the SF2000 save state file you want to convert from. The save states are usually stored in the save subfolder where the ROM for the game is located. There can be up to four saves states per game, with the extensions .sa0, .sa1, .sa2 and .sa3 respectively.

"; + // Create the new section, add our heading and our instruction + // paragraph... + let html = "

Step 2: Select SF2000 Save State File

Select the SF2000 save state file you want to convert from. The save states are usually stored in the save subfolder where the ROM for the game is located. There can be up to four saves states per game, with the extensions .sa0, .sa1, .sa2 and .sa3 respectively.

"; // Add our file chooser... html += "
"; @@ -82,16 +83,18 @@ // Close off our section... html += "
"; - // Finally, add a
separator after the last step, and append the new step... + // Finally, add a
separator after the last step, and append the new + // step... document.getElementById("steps").insertAdjacentHTML("beforeend", "
"); document.getElementById("steps").insertAdjacentHTML("beforeend", html); // Attach our event handler to our new file input control... - var sf2000SaveInput = document.getElementById("inputSAFile"); + const sf2000SaveInput = document.getElementById("inputSAFile"); sf2000SaveInput.addEventListener("change", function() { - // The user has chosen a new file; it should be a binary blob we can try - // to interpret as a save state file. First let's clear any old messages... + // The user has chosen a new file; it should be a binary blob we can + // try to interpret as a save state file. First let's clear any old + // messages... document.getElementById("step2Messages").innerHTML = ""; // And also remove any old HTML after the current section... @@ -100,7 +103,7 @@ } // Now let's read in our file data as a binary stream... - var frSave = new FileReader(); + const frSave = new FileReader(); saveFileName = event.target.files[0].name; frSave.readAsArrayBuffer(event.target.files[0]); frSave.onload = function(event) { @@ -118,8 +121,8 @@ } else { - // Whoops, the file doesn't looke like it was an SF2000 save state file. - // Let the user know and return... + // Whoops, the file doesn't look like it was an SF2000 save state + // file. Let the user know and return... setMessage("error", "step2Messages", "ERROR: The selected file does not appear to be a valid SF2000 save state file."); return; } @@ -127,13 +130,13 @@ }); } - // This function sets up the HTML for "Convert to SF2000 > Step 2", selecting - // the non-SF2000 save state file the user wants to convert to an SF2000 save - // state bundle... + // This function sets up the HTML for "Convert to SF2000 > Step 2", + // selecting the non-SF2000 save state file the user wants to convert to + // an SF2000 save state bundle... function setupStepTwo_To() { // Start our new section, add our header and our instructions... - var html = "

Step 2: Save State File

Select the save state file from your non-SF2000 emulator that you want to convert to an SF2000-compatible save state bundle.

"; + let html = "

Step 2: Save State File

Select the save state file from your non-SF2000 emulator that you want to convert to an SF2000-compatible save state bundle.

"; // Add our file chooser... html += "
"; @@ -141,12 +144,13 @@ // Close our section... html += "
"; - // Finally, add a
separator after the last step, and append the new step... + // Finally, add a
separator after the last step, and append the new + // step... document.getElementById("steps").insertAdjacentHTML("beforeend", "
"); document.getElementById("steps").insertAdjacentHTML("beforeend", html); // Attach our event handler to our new file input control... - var userFileInput = document.getElementById("inputStateFile"); + const userFileInput = document.getElementById("inputStateFile"); userFileInput.addEventListener("change", function() { // First let's clear any old messages... @@ -158,31 +162,31 @@ } // Now let's read in the file... - var frImage = new FileReader(); + const frImage = new FileReader(); frImage.readAsDataURL(event.target.files[0]); frImage.onload = function(event) { - // The file is loaded; let's check to make sure we got a binary file (can't - // really do much more validation than that, alas)... + // The file is loaded; let's check to make sure we got a binary file + // (can't really do much more validation than that, alas)... if (!event.target.result.includes("application/octet-stream")) { - // Whoops! Doesn't look like the user provided a binary file, so can't be - // a save state! + // Whoops! Doesn't look like the user provided a binary file, so + // can't be a save state! setMessage("error", "step2Messages", "ERROR: The selected file does not appear to be a binary file, and therefore can't be a save state file; please make sure you're selecting a valid emulator save state file."); return; } - // If we're here then it was a binary file; read the data into our global - // saveData buffer... - var base64Data = event.target.result.substring(event.target.result.indexOf(",") + 1); - var binaryData = atob(base64Data); + // If we're here then it was a binary file; read the data into our + // global saveData buffer... + const base64Data = event.target.result.substring(event.target.result.indexOf(",") + 1); + const binaryData = atob(base64Data); saveData = new Uint8Array(binaryData.length); - for (var i = 0; i < binaryData.length; i++) { + for (let i = 0; i < binaryData.length; i++) { saveData[i] = binaryData.charCodeAt(i); } - // Let's check the provided data for Retroarch's save-state headers, and - // strip them out if we find them... + // Let's check the provided data for Retroarch's save-state headers, + // and strip them out if we find them... saveData = stripRetroarchHeaders(saveData); // On to Step 3! @@ -191,13 +195,14 @@ }); } - // This function sets up the HTML for "Convert from SF2000 > Step 2 > Step 3"... + // This function sets up the HTML for "Convert from SF2000 > Step 2 > Step + // 3"... function setupStepThree_From() { // In this section, we'll be presenting the user with the thumbnail of // their save state, and a download button for downloading the save // state data itself. Let's start building our HTML... - var html = "

Step 3: Download Save State Data

Below you'll find the thumbnail for your chosen save state file, as well as a download button which will let you download the raw save state data for use with other emulators/devices. If you want to save the thumbnail as well, just right-click or tap-and-hold on it like any other image, and save it that way.

"; + let html = "

Step 3: Download Save State Data

Below you'll find the thumbnail for your chosen save state file, as well as a download button which will let you download the raw save state data for use with other emulators/devices. If you want to save the thumbnail as well, just right-click or tap-and-hold on it like any other image, and save it that way.

"; // Next, we'll add the image preview... html += "
"; @@ -208,13 +213,14 @@ // ... and lastly we'll close off our section... html += "
"; - // Add a
separator after the previous step, and append the new step... + // Add a
separator after the previous step, and append the new + // step... document.getElementById("steps").insertAdjacentHTML("beforeend", "
"); document.getElementById("steps").insertAdjacentHTML("beforeend", html); - // Now, add our interactivity by attaching our event handler to the download - // button... - var downloadButton = document.getElementById("saveStateDownload"); + // Now, add our interactivity by attaching our event handler to the + // download button... + const downloadButton = document.getElementById("saveStateDownload"); downloadButton.addEventListener("click", function() { // Before we kick-off our download function, let's determine the file @@ -224,7 +230,7 @@ // strip off both extensions and *then* add .state (Retroarch save // state naming convention)... downloadName = saveFileName + ".state"; - var match = saveFileName.match(/(.+)\.([^.]+)\.([^.]+)/); + const match = saveFileName.match(/(.+)\.([^.]+)\.([^.]+)/); if (match) { downloadName = match[1] + ".state"; } @@ -237,7 +243,8 @@ convertFromSF2000AndRender(); } - // This function sets up the HTML for "Convert to SF2000 > Step 2 > Step 3"... + // This function sets up the HTML for "Convert to SF2000 > Step 2 > Step + // 3"... function setupStepThree_To() { // In this section, we'll be rendering the HTML for some controls - the @@ -246,7 +253,7 @@ // save state should be stored in slot 1, 2, 3 or 4 (again, required for // the file name). Let's start by creating our new section, and add its // heading and instruction paragraph... - html = "

Step 3: Select Game ROM And Slot Number

To name your new SF2000 save state properly, this tool needs to know two things - the name of the game ROM file the save state is for, and which save slot (1, 2, 3 or 4) the save state should take up. Use the controls below to select your game ROM file from your SF2000's microSD card, and to choose which save slot you want.

When you've chosen a ROM file and a slot number, the Download button will become enabled, and can be used to download your new save state file. Put it in the save subfolder along-side wherever your ROM file is stored on the SF2000's microSD card (e.g., if your ROM file is sd:/ROMS/Apotris.gba, and you chose save slot 1, you'd put the resulting save state in sd:/ROMS/save/Apotris.gba.sa0).

The one special case is with arcade games; the ROM you need to select is one of the ones in the sd:/ARCADE/bin/ folder, and the resulting save state bundle goes into sd:/ARCADE/save/. As an example, if you wanted to transfer a save state to the SF2000 for the game \"Metal Slug\" in slot 3, you'd select sd:/ARCADE/bin/mslug.zip and Slot 3, and the final location to put the save state would be sd:/ARCADE/save/mslug.zip.sa2 - I hope that makes sense! You can find a list of all potential arcade zip-file names and the full name of the game they represent here, if it helps - the first column (\"rom\") is the name of the zip-file without the .zip part, and the second column (\"fullname\") is the full name of the game/romset.

"; + let html = "

Step 3: Select Game ROM And Slot Number

To name your new SF2000 save state properly, this tool needs to know two things - the name of the game ROM file the save state is for, and which save slot (1, 2, 3 or 4) the save state should take up. Use the controls below to select your game ROM file from your SF2000's microSD card, and to choose which save slot you want.

When you've chosen a ROM file and a slot number, the Download button will become enabled, and can be used to download your new save state file. Put it in the save subfolder along-side wherever your ROM file is stored on the SF2000's microSD card (e.g., if your ROM file is sd:/ROMS/Apotris.gba, and you chose save slot 1, you'd put the resulting save state in sd:/ROMS/save/Apotris.gba.sa0).

The one special case is with arcade games; the ROM you need to select is one of the ones in the sd:/ARCADE/bin/ folder, and the resulting save state bundle goes into sd:/ARCADE/save/. As an example, if you wanted to transfer a save state to the SF2000 for the game \"Metal Slug\" in slot 3, you'd select sd:/ARCADE/bin/mslug.zip and Slot 3, and the final location to put the save state would be sd:/ARCADE/save/mslug.zip.sa2 - I hope that makes sense! You can find a list of all potential arcade zip-file names and the full name of the game they represent here, if it helps - the first column (\"rom\") is the name of the zip-file without the .zip part, and the second column (\"fullname\") is the full name of the game/romset.

"; // Now let's add our controls (file browser for the game ROM, and four // radio button inputs for the save state slot number); we'll group them @@ -264,17 +271,18 @@ // ... and lastly we'll close off our section... html += "
"; - // Add a
separator after the previous step, and append the new step... + // Add a
separator after the previous step, and append the new + // step... document.getElementById("steps").insertAdjacentHTML("beforeend", "
"); document.getElementById("steps").insertAdjacentHTML("beforeend", html); - // Now we'll attach event listeners for all of the interactive elements in - // this step. Mostly this will just be for storing the provided details; - // we'll also call a little helper function for each one which'll determine - // if the Download button should be enabled or not... + // Now we'll attach event listeners for all of the interactive elements + // in this step. Mostly this will just be for storing the provided + // details; we'll also call a little helper function for each one + // which'll determine if the Download button should be enabled or not... // Let's start with the ROM file select... - var romFileInput = document.getElementById("inputROMFile"); + const romFileInput = document.getElementById("inputROMFile"); romFileInput.addEventListener("change", function() { // First let's clear any old messages... @@ -289,9 +297,9 @@ romFileGood = false; // Now let's read in the file; we don't really care about its contents - // per-se (we only need it for its file name), but we may as well confirm - // it's a binary file while we're here... - var frImage = new FileReader(); + // per-se (we only need it for its file name), but we may as well + // confirm it's a binary file while we're here... + const frImage = new FileReader(); saveFileName = event.target.files[0].name; frImage.readAsDataURL(event.target.files[0]); frImage.onload = function(event) { @@ -322,8 +330,8 @@ }); // Next up, our radio buttons... - var slots = document.getElementsByName("saveSlot"); - for (var i = 0; i < slots.length; i++) { + const slots = document.getElementsByName("saveSlot"); + for (let i = 0; i < slots.length; i++) { slots[i].addEventListener("change", function() { // The only thing we'll do here is run our Download button // state-checking function; we'll pull the actual data from the @@ -333,14 +341,14 @@ } // And last but not least, our Download button... - var dButton = document.getElementById("saveStateDownload"); + const dButton = document.getElementById("saveStateDownload"); dButton.addEventListener("click", function() { // We're actually going to be doing quite a bit here - we'll assemble // the binary blob of the new save state bundle before handing it off - // to our download function. First, let's try zlib-deflating the user's - // save state data... - var compressedSaveState = compressZlib(saveData); + // to our download function. First, let's try zlib-deflating the + // user's save state data... + const compressedSaveState = compressZlib(saveData); if (!(compressedSaveState instanceof Uint8Array)) { // Oh! We failed to compress the save state data using the pako // library for some reason... display a message to the user and @@ -349,7 +357,8 @@ return; } - // We also need to check our compressed data is of an appropriate size... + // We also need to check our compressed data is of an appropriate + // size... if (compressedSaveState.length < 0 || compressedSaveState.length > 4294967295) { setMessage("error", "step3Messages", "ERROR: Compressed save state data is not valid (either it was 0 bytes in length, or was larger than 4 GB)!"); return false; @@ -361,7 +370,7 @@ // We'll also use a little "offset" variable, to help keep track of // where we are in the array as we go... - var offset = 0; + let offset = 0; // The first four bytes are a little-endian Uint32 version of our // compressed save state data's length... @@ -371,34 +380,33 @@ saveData[offset + 3] = (compressedSaveState.length >> 24) & 0xFF; offset += 4; - // The next sequence of bytes are all of our compressed save state data, - // so let's just copy it in... - for (var i = 0; i < compressedSaveState.length; i++) { + // The next sequence of bytes are all of our compressed save state + // data, so let's just copy it in... + for (let i = 0; i < compressedSaveState.length; i++) { saveData[offset] = compressedSaveState[i]; offset += 1; } // For reasons, we'll need our current offset a little later on, so // let's make a note of it now... - var thumbnailStart = offset; + const thumbnailStart = offset; // The next three sets of four bytes are little-endian Uint32 versions // of our thumbnail image's width, height and compressed data length; // I've got all of those numbers pre-calculated, so I'll just add them - // in... - // Width... + // in, starting with width... saveData[offset + 0] = 64; saveData[offset + 1] = 1; saveData[offset + 2] = 0; saveData[offset + 3] = 0; offset += 4; - // Height... + // ... and then height... saveData[offset + 0] = 240; saveData[offset + 1] = 0; saveData[offset + 2] = 0; saveData[offset + 3] = 0; offset += 4; - // Length... + // ... and finally length... saveData[offset + 0] = 107; saveData[offset + 1] = 62; saveData[offset + 2] = 0; @@ -408,7 +416,7 @@ // Next comes the zlib compressed thumbnail data itself; I've already // got that too :) thumbData = atob("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"); - for (var i = 0; i < thumbData.length; i++) { + for (let i = 0; i < thumbData.length; i++) { saveData[offset] = thumbData.charCodeAt(i); offset += 1; } @@ -449,40 +457,39 @@ }); } - // This function takes in a Uint8Array object, and attempts to inflate it using - // the pako library; returns a Uint8Array object containing the inflated data - // on success, or null on failure... + // This function takes in a Uint8Array object, and attempts to inflate it + // using the pako library; returns a Uint8Array object containing the + // inflated data on success, or null on failure... function decompressZlib(data) { if (!(data instanceof Uint8Array)) { return null; } try { - let decompressed = pako.inflate(data); - return decompressed; + return pako.inflate(data); } catch (error) { return null; } } // This function takes in a Uint8Array object, and attempts to deflate it - // using the pako library; returns a Uint8Array object containing the defalated - // data on success, or null on failure... + // using the pako library; returns a Uint8Array object containing the + // deflated data on success, or null on failure... function compressZlib(data) { if (!(data instanceof Uint8Array)) { return null; } try { - let compressed = pako.deflate(data); - return compressed; + return pako.deflate(data); } catch (error) { return null; } } - // This function validates that a provided Uint8Array object matches the format - // of an SF2000 save state file, and assigns the state data, thumbnail data and - // thumbnail dimensions to our global storage variables. The function returns - // true when successfully processing the array, or false otherwise... + // This function validates that a provided Uint8Array object matches the + // format of an SF2000 save state file, and assigns the state data, + // thumbnail data and thumbnail dimensions to our global storage + // variables. The function returns true when successfully processing the + // array, or false otherwise... function validateSave(data) { // First check if data is a Uint8Array object... @@ -495,15 +502,18 @@ return false; } - // Read the first four bytes as a little-endian number and store it in saveLength... - let saveLength = data[0] + (data[1] << 8) + (data[2] << 16) + (data[3] << 24); + // Read the first four bytes as a little-endian number and store it in + // saveLength... + const saveLength = data[0] + (data[1] << 8) + (data[2] << 16) + (data[3] << 24); - // Check if saveLength is positive and does not exceed the remaining bytes... + // Check if saveLength is positive and does not exceed the remaining + // bytes... if (saveLength <= 0 || saveLength > data.length - 16) { return false; } - // Check if the next two bytes are [120, 156] (zlib header used by SF2000)... + // Check if the next two bytes are [120, 156] (zlib header used by the + // SF2000)... if (data[4] !== 120 || data[5] !== 156) { return false; } @@ -514,7 +524,8 @@ return false; } - // Read the next four bytes as a little-endian number and store it in thumbWidth... + // Read the next four bytes as a little-endian number and store it in + // thumbWidth... thumbWidth = data[4 + saveLength] + (data[5 + saveLength] << 8) + (data[6 + saveLength] << 16) + (data[7 + saveLength] << 24); // Check if thumbWidth is positive... @@ -522,7 +533,8 @@ return false; } - // Read the next four bytes as a little-endian number and store it in thumbHeight... + // Read the next four bytes as a little-endian number and store it in + // thumbHeight... thumbHeight = data[8 + saveLength] + (data[9 + saveLength] << 8) + (data[10 + saveLength] << 16) + (data[11 + saveLength] << 24); // Check if thumbHeight is positive... @@ -530,20 +542,24 @@ return false; } - // Read the next four bytes as a little-endian number and store it in thumbLength... - let thumbLength = data[12 + saveLength] + (data[13 + saveLength] << 8) + (data[14 + saveLength] << 16) + (data[15 + saveLength] << 24); + // Read the next four bytes as a little-endian number and store it in + // thumbLength... + const thumbLength = data[12 + saveLength] + (data[13 + saveLength] << 8) + (data[14 + saveLength] << 16) + (data[15 + saveLength] << 24); - // Check if thumbLength is positive and does not exceed the remaining bytes... + // Check if thumbLength is positive and does not exceed the remaining + // bytes... if (thumbLength <= 0 || thumbLength > data.length - (16 + saveLength)) { return false; } - // Check if the next two bytes are [120, 156] (zlib header used by SF2000)... + // Check if the next two bytes are [120, 156] (zlib header used by the + // SF2000)... if (data[16 + saveLength] !== 120 || data[17 + saveLength] !== 156) { return false; } - // Store the next thumbLength bytes in thumbData, and attempt to inflate it... + // Store the next thumbLength bytes in thumbData, and attempt to inflate + // it... thumbData = decompressZlib(data.slice(16 + saveLength, 16 + saveLength + thumbLength)); if (!(thumbData instanceof Uint8Array)) { return false; @@ -555,10 +571,10 @@ } // Store the last four bytes in lastFour... - let lastFour = data[data.length - 4] + (data[data.length -3 ] <<8 )+ (data[data.length -2 ]<<16 )+ (data[data.length -1 ]<<24 ); + const lastFour = data[data.length - 4] + (data[data.length -3 ] <<8 )+ (data[data.length -2 ]<<16 )+ (data[data.length -1 ]<<24 ); - // Confirm the last four bytes equal the offset within the data of thumbWidth, - // i.e., first zlib stream length + 4... + // Confirm the last four bytes equal the offset within the data of + // thumbWidth, i.e., first zlib stream length + 4... if (lastFour !== saveLength + 4) { return false; } @@ -567,20 +583,22 @@ return true; } - // This function runs through our binary save state data, and checks it for any - // of Retroarch's save state headers. If it finds them, it strips them out... + // This function runs through our binary save state data, and checks it + // for any of Retroarch's save state headers. If it finds them, it strips + // them out... // See https://github.com/libretro/RetroArch/blob/master/tasks/task_save.c function stripRetroarchHeaders(input) { - // Define an array of the possible Retroarch headers that we're looking for... - let headers = ["RASTATE" + String.fromCharCode([1]), "MEM ", "RPLY", "ACHV", "END "]; + // Define an array of the possible Retroarch headers that we're looking + // for... + const headers = ["RASTATE" + String.fromCharCode([1]), "MEM ", "RPLY", "ACHV", "END "]; - // Define a helper function that checks if a header starts at the provided - // index in the data stream... + // Define a helper function that checks if a header starts at the + // provided index in the data stream... function isHeader(array, i) { // Starting at an index of i, convert the next 8 bytes to a string... - let str = String.fromCharCode(...array.slice(i, i + 8)); + const str = String.fromCharCode(...array.slice(i, i + 8)); // Check if the string includes any of our defined headers... for (let i = 0; i < headers.length; i++) { @@ -595,23 +613,25 @@ return false; } - // Create an intermediate storage array with the same length as input; we - // don't know how long our output length will be yet, but it won't be any - // longer than input, and this array will hold our modified data while we - // work... + // Create an intermediate storage array with the same length as input; + // we don't know how long our output length will be yet, but it won't be + // any longer than input, and this array will hold our modified data + // while we work... let intermediate = new Uint8Array(input.length); - // Now, it's on to the meat of this function. We're going to loop through - // all of our current save state data, byte by byte, and check to see if - // we find a Retroarch save state header starting at the current byte. If - // we don't, we just copy the current byte to our intermediate storage - // array. If we *do* find a Retroarch header, we skip forward 8 bytes (all - // the Retroarch save state header blocks are 8 bytes long)... + // Now, it's on to the meat of this function. We're going to loop + // through all of our current save state data, byte by byte, and check + // to see if we find a Retroarch save state header starting at the + // current byte. If we don't, we just copy the current byte to our + // intermediate storage array. If we *do* find a Retroarch header, we + // skip forward 8 bytes (all the Retroarch save state header blocks are + // 8 bytes long)... let index = 0; let intermediateIndex = 0; while (index < input.length && !isNaN(input[index])) { - // Check if we have a header starting at the current index in our data... + // Check if we have a header starting at the current index in our + // data... if (isHeader(input, index)) { // We do! Don't write any data, and just skip forward 8 bytes... @@ -630,7 +650,7 @@ } // If we're here, intermediate should contain our final output data. - // If the output data length is the same as the input data lenth, then + // If the output data length is the same as the input data length, then // we didn't change anything and can just return the input. Otherwise, // we stripped *something* from the input data... so we'll copy our // new, shorter intermediate data to a correctly sized Uint8Array @@ -647,19 +667,22 @@ } } - // This function checks if we're both happy that the user-selected ROM file - // is binary, AND that a save state slot radio button has been selected; if - // both are true, it enables the download button, otherwise it makes sure - // the button is disabled... + // This function checks if we're both happy that the user-selected ROM + // file is binary, AND that a save state slot radio button has been + // selected; if both are true, it enables the download button, otherwise + // it makes sure the button is disabled... function userStateDownloadCheck() { + if (romFileGood === true && document.querySelector('input[name="saveSlot"]:checked')) { + // Looks good - enable Download button! - var dButton = document.getElementById("saveStateDownload"); + const dButton = document.getElementById("saveStateDownload"); dButton.removeAttribute("disabled"); } else { + // No good - disable Download button! - var dButton = document.getElementById("saveStateDownload"); + const dButton = document.getElementById("saveStateDownload"); dButton.setAttribute("disabled", ""); } } @@ -669,8 +692,8 @@ // global; it then renders that ImageData to our preview canvas... function convertFromSF2000AndRender() { previewCanvasData = rgb565ToImageData(thumbData, thumbWidth, thumbHeight); - var canvas = document.getElementById("thumbnailPreview"); - var context = canvas.getContext("2d"); + const canvas = document.getElementById("thumbnailPreview"); + const context = canvas.getContext("2d"); canvas.width = previewCanvasData.width; canvas.height = previewCanvasData.height; context.clearRect(0, 0, canvas.width, canvas.height); @@ -678,6 +701,6 @@ }
-

CC0: public domain. Version 1.3, 20240223.1

+

CC0: public domain. Version 1.4, 20240514.1

diff --git a/tools/tools.css b/tools/tools.css index b46bfaf..a96c1a7 100644 --- a/tools/tools.css +++ b/tools/tools.css @@ -11,6 +11,10 @@ Just like the tools themselves, this file should be considered CC0 Public Domain (https://creativecommons.org/publicdomain/zero/1.0/) + Version 1.1: Added a max-width to the body element, and auto margins to center + things again (just stops the page being too wide on full-screen desktop + browsers). + Version 1.0: Initial version */ @@ -44,6 +48,8 @@ body { background-color: var(--background); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, helvetica neue, helvetica, Cantarell, Ubuntu, roboto, noto, arial, sans-serif; + max-width: 80em; + margin: 0 auto; } a, a:visited, a:hover, a:active { color: inherit; } diff --git a/tools/tools.js b/tools/tools.js index a7ef6ea..9e878b9 100644 --- a/tools/tools.js +++ b/tools/tools.js @@ -12,6 +12,17 @@ Just like the tools themselves, this file should be considered CC0 Public Domain (https://creativecommons.org/publicdomain/zero/1.0/) + Version 1.7: Added ordered dithering support to imageDataToRgb565(). Also + swapped out the Gaussian resampling function in scaleImage() for a higher + quality algorithm based on a semi-custom hybrid between a looped 50% + bilinear downsampling scheme and a Hermite interpolation resampler. Added a + "keepAlpha" argument to imageToImageData() (defaults to true) - if false, + the provided image is drawn on a black background, removing any alpha + channel; this was added as a utility for the boot logo changer. Also + generally tidied up the code here and there (changed vars to lets/consts, + replaced the SVG icons with simple emoji, fixed a few small logic edge + cases, etc.) + Version 1.6: Added support for the (hopefully not broken) October 13th BIOS in getFirmwareHash() and knownHash() @@ -44,17 +55,17 @@ // 0x18f with an updated CRC32 calculated on bytes 512 to the end of the // array. Credit to `bnister` for this code! function patchCRC32(data) { - var c; - var tabCRC32 = new Int32Array(256); - for (var i = 0; i < 256; i++) { + let c; + const tabCRC32 = new Int32Array(256); + for (let i = 0; i < 256; i++) { c = i << 24; - for (var j = 0; j < 8; j++) { + for (let j = 0; j < 8; j++) { c = c & (1 << 31) ? c << 1 ^ 0x4c11db7 : c << 1; } tabCRC32[i] = c; } c = ~0; - for (var i = 512; i < data.length; i++) { + for (let i = 512; i < data.length; i++) { c = c << 8 ^ tabCRC32[c >>> 24 ^ data[i]]; } data[0x18c] = c & 255; @@ -71,7 +82,7 @@ function getFirmwareHash(data) { // we're going to be manipulating that data before generating our hash, but we // don't want to modify the original object at all... so we'll create a copy, // and work only on the copy... - var dataCopy = data.slice(); + const dataCopy = data.slice(); // Only really worthwhile doing this for big bisrv.asd files... if (dataCopy.length > 12600000) { @@ -87,9 +98,9 @@ function getFirmwareHash(data) { // level indicator. These unfortunately can't be searched for - they're just // in specific known locations for specific firmware versions... // Location: Approximately 0x35A8F8 (about 25% of the way through the file) - var prePowerCurve = findSequence([0x11, 0x05, 0x00, 0x02, 0x24], dataCopy); + const prePowerCurve = findSequence([0x11, 0x05, 0x00, 0x02, 0x24], dataCopy); if (prePowerCurve > -1) { - var powerCurveFirstByteLocation = prePowerCurve + 5; + const powerCurveFirstByteLocation = prePowerCurve + 5; switch (powerCurveFirstByteLocation) { case 0x35A8F8: // Seems to match mid-March layout... @@ -156,11 +167,11 @@ function getFirmwareHash(data) { // Next identify the emulator button mappings (if they exist), and blank // them out too... // Location: Approximately 0x8D6200 (about 75% of the way through the file) - var preButtonMapOffset = findSequence([0x00, 0x00, 0x00, 0x71, 0xDB, 0x8E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], dataCopy); + const preButtonMapOffset = findSequence([0x00, 0x00, 0x00, 0x71, 0xDB, 0x8E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], dataCopy); if (preButtonMapOffset > -1) { - var postButtonMapOffset = findSequence([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00], dataCopy, preButtonMapOffset); + const postButtonMapOffset = findSequence([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00], dataCopy, preButtonMapOffset); if (postButtonMapOffset > -1) { - for (var i = preButtonMapOffset + 16; i < postButtonMapOffset; i++) { + for (let i = preButtonMapOffset + 16; i < postButtonMapOffset; i++) { dataCopy[i] = 0x00; } } @@ -174,10 +185,10 @@ function getFirmwareHash(data) { // Next identify the boot logo position, and blank it out too... // Location: Approximately 0x9B3520 (about 80% of the way through the file) - var badExceptionOffset = findSequence([0x62, 0x61, 0x64, 0x5F, 0x65, 0x78, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6F, 0x6E, 0x00, 0x00, 0x00], dataCopy); + const badExceptionOffset = findSequence([0x62, 0x61, 0x64, 0x5F, 0x65, 0x78, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6F, 0x6E, 0x00, 0x00, 0x00], dataCopy); if (badExceptionOffset > -1) { - var bootLogoStart = badExceptionOffset + 16; - for (var i = bootLogoStart; i < (bootLogoStart + 204800); i++) { + const bootLogoStart = badExceptionOffset + 16; + for (let i = bootLogoStart; i < (bootLogoStart + 204800); i++) { dataCopy[i] = 0x00; } } @@ -189,10 +200,10 @@ function getFirmwareHash(data) { // CPU cycles, in case folks want to patch those bytes to correct SNES // first-launch issues on newer firmwares... // Location: Approximately 0xC0A170 (about 99% of the way through the file) - var preSNESBytes = findSequence([0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x80], dataCopy); + const preSNESBytes = findSequence([0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x80], dataCopy); if (preSNESBytes > -1) { - var snesAudioBitrateBytes = preSNESBytes + 8; - var snesCPUCyclesBytes = snesAudioBitrateBytes + 8; + const snesAudioBitrateBytes = preSNESBytes + 8; + const snesCPUCyclesBytes = snesAudioBitrateBytes + 8; dataCopy[snesAudioBitrateBytes] = 0x00; dataCopy[snesAudioBitrateBytes + 1] = 0x00; dataCopy[snesCPUCyclesBytes] = 0x00; @@ -208,9 +219,8 @@ function getFirmwareHash(data) { // against some known values... return crypto.subtle.digest("SHA-256", dataCopy.buffer) .then(function(digest) { - var array = Array.from(new Uint8Array(digest)); - var hash = array.map(byte => ("00" + byte.toString(16)).slice(-2)).join(""); - return hash; + const array = Array.from(new Uint8Array(digest)); + return array.map(byte => ("00" + byte.toString(16)).slice(-2)).join(""); }) .catch(function(error) { return false; @@ -224,26 +234,21 @@ function getFirmwareHash(data) { // This function searches for array needle in array haystack starting at offset // and returns the zero-based index of the first match found, or -1 if not // found... -function findSequence(needle, haystack, offset) { - - // If offset is not provided, default to 0... - offset = offset || 0; +function findSequence(needle, haystack, offset = 0) { // Loop through the haystack array starting from the offset... - for (var i = offset; i < haystack.length - needle.length + 1; i++) { + for (let i = offset; i < haystack.length - needle.length + 1; i++) { // Assume a match until proven otherwise... - var match = true; + let match = true; // Loop through the needle array and compare each byte... - for (var j = 0; j < needle.length; j++) { - + for (let j = 0; j < needle.length; j++) { if (haystack[i + j] !== needle[j]) { // Mismatch found, break the inner loop and continue the outer loop... match = false; break; } - } // If match is still true after the inner loop, we have found a match; @@ -261,7 +266,7 @@ function findSequence(needle, haystack, offset) { // download... function downloadToBrowser(data, type, name) { // Send the data to the user's browser as a file download... - var link = document.createElement("a"); + const link = document.createElement("a"); link.href = window.URL.createObjectURL(new Blob([data], {type: type})); link.download = name; link.style.display = "none"; @@ -304,33 +309,103 @@ function knownHash(hash) { } // Takes in an ImageData object, and returns a Uint8Array object containing the -// data in little-endian RGB565 format... -function imageDataToRgb565(input) { +// data in little-endian RGB565 format. Optionally supports applying ordered +// dithering to the input... +function imageDataToRgb565(input, dither = false, ditherStrength = 0.2) { + + // Pre-define a Bayer 8x8 matrix for ordered dithering (if enabled); also, + // this matrix was shamelessly yoinked from the Wikipedia example! + const bayerMatrix = [ + [ 0, 32, 8, 40, 2, 34, 10, 42], + [48, 16, 56, 24, 50, 18, 58, 26], + [12, 44, 4, 36, 14, 46, 6, 38], + [60, 28, 52, 20, 62, 30, 54, 22], + [ 3, 35, 11, 43, 1, 33, 9, 41], + [51, 19, 59, 27, 49, 17, 57, 25], + [15, 47, 7, 39, 13, 45, 5, 37], + [63, 31, 55, 23, 61, 29, 53, 21] + ]; // Loop through the image data, and convert it to little-endian RGB565. First, // we'll store the raw RGB565-converted integers in an array, one entry per // pixel... - var intArray = []; - var pixelCount = 0; - for (var i = 0; i < input.data.length; i += 4){ + const intArray = []; + for (let i = 0; i < input.data.length; i += 4){ // Read in the raw source RGB colours from the image data stream... - var red = input.data[i]; - var green = input.data[i+1]; - var blue = input.data[i+2]; + let red = input.data[i]; + let green = input.data[i+1]; + let blue = input.data[i+2]; - // Use some shifting and masking to get a big-endian version of the RGB565 - // colour and store it in our array before moving on... - intArray[pixelCount] = ((red & 248)<<8) + ((green & 252)<<3) + (blue>>3); - pixelCount++; + // Check if we're going to dither or not... + if (dither) { + + // We are! The ordered dither algorithm is kinda messy, but essentially we + // just end up slightly brightening/darkening our source colour pixels, + // using the matrix defined above - this essentially adds a kind of + // "noise" to the image, which stops banding being as apparent when + // dropping down to RGB565. The first thing we need to do is calculate our + // X and Y coordinates within the Bayer matrix for the current source + // image pixel... + const bayerX = (i / 4) % input.width % 8; + const bayerY = (Math.floor(i / 4 / input.width)) % 8; + + // The Wikipedia Bayer matrix was designed to work with colour values that + // range from 0 to 63... which is great for our RGB565 green colour + // channel, but not for red or blue - so we scale the matrix values to + // range from 0 to 31 for those two colour channels. Also, we want to both + // lighten *and* darken our source input values, so to do that we subtract + // roughly half the *possible* maximum from each value - 16 for red and + // blue (half of 31, rounded up), and 32 for green (half of 63, rounded + // up); note I'm using actual values of 18 and 36 instead of 16 and 32, as + // I found that the Wikipedia matrix tended towards lightening more than + // darkening, so I'm compensating by offsetting downwards a little more... + const bayerValueRedBlue = (bayerMatrix[bayerY][bayerX] / 63 * 31) - 18; + const bayerValueGreen = bayerMatrix[bayerY][bayerX] - 36; + + // Now we apply the ordered dithering itself; basically this "adds" the + // Bayer matrix values to red, green and blue (which might lighten or + // darken the pixel, depending on the specific value), and then uses that + // value as a percentage of 255 (the highest possible value) to scale our + // *actual* RGB565 output values for the pixel (which is a maximum of 31 + // for red and blue, and 63 for green). We also scale the whole effect by + // ditherStrength, so that we can adjust how strong or weak the overall + // dithering noise is (too strong and its distracting, too weak and it + // mightn't effectively cover up banding in the output image). We scale + // the green colour channel half as much as red and blue, as otherwise at + // high dither strengths the image would take on an increasingly green + // color cast... + red = Math.round(31 * (Math.min(255, Math.max(0, red + (bayerValueRedBlue * ditherStrength))) / 255)); + green = Math.round(63 * (Math.min(255, Math.max(0, green + (bayerValueGreen * ditherStrength * 0.5))) / 255)); + blue = Math.round(31 * (Math.min(255, Math.max(0, blue + (bayerValueRedBlue * ditherStrength))) / 255)); + + // As a result of the multiplying above, it's possible our red, green and + // blue values are now outside of the 0-31/63 ranges that are allowed for + // our RGB565 output - so we need to clamp the values, just in case... + red = Math.min(31, Math.max(0, red)); + green = Math.min(63, Math.max(0, green)); + blue = Math.min(31, Math.max(0, blue)); + + // And finally, we take our values and convert them to an int representing + // the RGB565 value, that will eventually be stuffed into our output + // Uint8Array object... + intArray[i / 4] = (red << 11) + (green << 5) + blue; + } + else { + + // We're not dithering, so all we need to do is use some shifting and + // masking to get a big-endian version of the RGB565 colour and store it + // in our array before moving on... + intArray[i / 4] = ((red & 248)<<8) + ((green & 252)<<3) + (blue>>3); + } } // Create a data buffer and a data view; we'll use the view to convert our int // array data to little-endian format (the "true" below) to be stored in the // buffer... - var buffer = new ArrayBuffer(intArray.length * 2); - var dataView = new DataView(buffer); - for (var i = 0; i < intArray.length; i++) { + const buffer = new ArrayBuffer(intArray.length * 2); + const dataView = new DataView(buffer); + for (let i = 0; i < intArray.length; i++) { dataView.setInt16(i * 2, intArray[i], true); } @@ -344,8 +419,8 @@ function imageDataToBgra(input) { // This is pretty simple - we just loop through the input data (which is in // RGBA format), and swap the Red and Blue channels to output BGRA instead... - output = new Uint8Array(input.data.length); - for (var i = 0; i < input.data.length; i += 4) { + const output = new Uint8Array(input.data.length); + for (let i = 0; i < input.data.length; i += 4) { output[i] = input.data[i + 2]; output[i + 1] = input.data[i + 1]; output[i + 2] = input.data[i]; @@ -360,25 +435,25 @@ function rgb565ToImageData(input, width, height) { // Create an output ImageData object of the specified dimensions; it'll // default to transparent black, but we'll fill it with our input data... - output = new ImageData(width, height); - outputIndex = 0; - for (var i = 0; i < input.length; i += 2) { + const output = new ImageData(width, height); + let outputIndex = 0; + for (let i = 0; i < input.length; i += 2) { // Check to make sure we haven't run out of space in our output buffer... if (outputIndex < output.data.length) { // Read in two bytes, representing one RGB565 pixel in little-endian // format... - var byte1 = input[i]; - var byte2 = input[i + 1]; + const byte1 = input[i]; + const byte2 = input[i + 1]; // Extract the red, green and blue components from them. The first five // bits of byte2 are red, the first three bits of byte1 and the last // three bits of byte 2 are green, and the last five bits of byte1 are // blue... - var red = (byte2 & 0b11111000) >> 3; - var green = ((byte1 & 0b11100000) >> 5) | ((byte2 & 0b00000111) << 3); - var blue = byte1 & 0b00011111; + let red = (byte2 & 0b11111000) >> 3; + let green = ((byte1 & 0b11100000) >> 5) | ((byte2 & 0b00000111) << 3); + let blue = byte1 & 0b00011111; // These values are in 5-bit/6-bit ranges; we need to scale them to 8-bit // ranges for the colours to look right... @@ -422,9 +497,9 @@ function bgraToImageData(input, width, height) { // Create an output ImageData object of the specified dimensions; it'll // default to transparent black, but we'll fill it with our input data... - output = new ImageData(width, height); - outputIndex = 0; - for (var i = 0; i < input.length; i += 4) { + const output = new ImageData(width, height); + let outputIndex = 0; + for (let i = 0; i < input.length; i += 4) { // Check to make sure we haven't run out of space in our output buffer... if (outputIndex < output.data.length) { @@ -461,193 +536,302 @@ function bgraToImageData(input, width, height) { // This function takes in a Javascript Image object, and outputs it as an // ImageData object instead... -function imageToImageData(image) { +function imageToImageData(image, keepAlpha = true) { // Create a virtual canvas, and load it up with our image file... - var canvas = document.createElement("canvas"); - var context = canvas.getContext("2d"); + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); canvas.width = image.width; canvas.height = image.height; // Draw our image to the canvas, which will allow us to get data about the - // image... + // image. If the user doesn't want to keep the alpha channel, draw the image + // on a black background... + if (!keepAlpha) { + context.fillStyle = "black"; + context.fillRect(0, 0, image.width, image.height); + } context.drawImage(image, 0, 0, image.width, image.height); - var imageData = context.getImageData(0, 0, image.width, image.height); + const imageData = context.getImageData(0, 0, image.width, image.height); // Return the ImageData object... return imageData; } -// This function takes in an ImageData object, and returns a new ImageData -// object containing a scaled version of the original image. The new image's -// width, height and scaling method are also specified... -function scaleImage(input, newWidth, newHeight, method) { +// This function takes in an ImageData object "input", and returns a new +// ImageData object containing a scaled version of the original image resized to +// "newWidth" and "newHeight". Two different scaling methods are supported: +// "Nearest Neighbour", and "Bilinear", although specifically when downscaling, +// "Bilinear" is just a friendly name for two different scaling +// filters/techniques that are used in a hybrid approach - halve-to-target, and +// Hermite interpolation... +function scaleImage(input, newWidth, newHeight, method, downscaleFilter = "hermite") { - // Utility function which takes in an ImageData object, and returns - // a (partially) upscaled version using bilinear filtering... + // Utility function which takes in an ImageData object, and returns a scaled + // version using bilinear filtering... function _bilinear(imageData, newWidth, newHeight) { - var canvas = document.createElement("canvas"); - var context = canvas.getContext("2d"); - canvas.width = imageData.width; - canvas.height = imageData.height; - context.putImageData(imageData, 0, 0); - var outCanvas = document.createElement("canvas"); - var outContext = outCanvas.getContext("2d"); - outContext.imageSmoothingEnabled = true; - outContext.imageSmoothingQuality = "high"; - outCanvas.width = newWidth; - outCanvas.height = newHeight; - outContext.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, newWidth, newHeight); - - return outContext.getImageData(0, 0, newWidth, newHeight); - } - - // Utility function which takes in an ImageData object, and returns - // a (partially) downscaled version using gaussian resampling... - // [Side note: I asked Bing AI for this function; ain't technology grand!] - function _gaussian(imageData, newWidth, newHeight) { - - // Get the original width and height of the image data - let oldWidth = imageData.width; - let oldHeight = imageData.height; - - // Get the pixel data array of the image data - let oldData = imageData.data; - - // Create a new pixel data array for the scaled image data - let newData = new Uint8ClampedArray(newWidth * newHeight * 4); - - // Calculate the scaling factor along each axis - let scaleX = newWidth / oldWidth; - let scaleY = newHeight / oldHeight; - - // Calculate the radius of the Gaussian kernel based on the scaling factor - let radiusX = Math.ceil(1 / scaleX); - let radiusY = Math.ceil(1 / scaleY); - - // Calculate the size of the Gaussian kernel along each axis - let sizeX = radiusX * 2 + 1; - let sizeY = radiusY * 2 + 1; - - // Create a Gaussian kernel array - let kernel = new Float32Array(sizeX * sizeY); - - // Calculate the standard deviation of the Gaussian distribution based on - // the radius - let sigmaX = radiusX / 3; - let sigmaY = radiusY / 3; - - // Calculate the inverse of the variance of the Gaussian distribution along - // each axis - let invVarX = 1 / (2 * sigmaX * sigmaX); - let invVarY = 1 / (2 * sigmaY * sigmaY); - - // Calculate the normalization factor for the Gaussian kernel - let norm = Math.sqrt(2 * Math.PI * sigmaX * sigmaY); - - // Loop through each element in the Gaussian kernel array - for (let ky = -radiusY; ky <= radiusY; ky++) { - for (let kx = -radiusX; kx <= radiusX; kx++) { - // Calculate the index of the element in the Gaussian kernel array - let k = (ky + radiusY) * sizeX + (kx + radiusX); - - // Calculate the value of the element using the Gaussian formula - kernel[k] = Math.exp(-(kx * kx) * invVarX - (ky * ky) * invVarY) / norm; - } + // Just in case, let's check to see if imageData's dimensions are already at + // the target width and height - if they are, there's no need to do anything + // to it, and we can just return it as-is... + if (imageData.width == newWidth && imageData.height == newHeight) { + return imageData; } - // Loop through each pixel in the new image data - for (let y = 0; y < newHeight; y++) { - for (let x = 0; x < newWidth; x++) { - // Calculate the corresponding coordinates in the old image data - let oldX = x / scaleX; - let oldY = y / scaleY; + // If we're here, then nope - we have some scaling to do! Create a canvas + // object and draw our input ImageData object to it... + const inputCanvas = document.createElement("canvas"); + const inputContext = inputCanvas.getContext("2d"); + inputCanvas.width = imageData.width; + inputCanvas.height = imageData.height; + inputContext.putImageData(imageData, 0, 0); - // Initialize the RGBA values of the pixel to zero - let r7 = 0; - let g7 = 0; - let b7 = 0; - let a7 = 0; + // Create another canvas object with the target dimensions, and draw the + // inputCanvas to it at target dimensions; this utilises the browser's + // native bilinear filtering... + const outputCanvas = document.createElement("canvas"); + const outputContext = outputCanvas.getContext("2d"); + outputContext.imageSmoothingEnabled = true; + outputContext.imageSmoothingQuality = "high"; + outputCanvas.width = newWidth; + outputCanvas.height = newHeight; + outputContext.drawImage(inputCanvas, 0, 0, inputCanvas.width, inputCanvas.height, 0, 0, newWidth, newHeight); - // Initialize the sum of the kernel values to zero - let sum = 0; + // Return an ImageData object pulled from the output canvas... + return outputContext.getImageData(0, 0, newWidth, newHeight); + } - // Loop through each element in the Gaussian kernel array - for (let ky = -radiusY; ky <= radiusY; ky++) { - for (let kx = -radiusX; kx <= radiusX; kx++) { - // Calculate the index of the element in the Gaussian kernel array - let k = (ky + radiusY) * sizeX + (kx + radiusX); + // Utility function which takes in an ImageData object, and returns a + // downscaled version using a technique that involves downscaling the input's + // width and height repeatedly by half until doing so again would take the + // image below the target dimensions. The result is then blended with the next + // scale down based on how close the target dimensions are to the current or + // next scale - this essentially gives an effect like mip-mapping in 3D games. + // This method is fast, doesn't introduce image distortion, and works great + // with alpha channels... but can give "soft" images if the target image + // dimensions are just under native scale (or a 50% downscale threshold), and + // can give a "harsh" image if the target dimensions are just above a + // downscale threshold. I try to mitigate the latter by blending in an + // additional downscale when we get close to a downscale threshold. This + // function and sub-functions are based on the following JSFiddle code, but + // with my own modifications: https://jsfiddle.net/1b68eLdr/93089/ + function _halveToTarget(imageData, newWidth, newHeight) { - // Get the value of the element in the Gaussian kernel array - let w = kernel[k]; + // This sub-function takes two ImageData objects (assumed to be of identical + // dimensions) and blends their RGBA data together based on the passed-in + // amount (between 0 and 1 inclusive) of how much of imageDataOne should be + // in the blend. An amount of 1 means the result will be purely + // imageDataOne, while an amount of 0 means the result will be purely + // imageDataTwo. Numbers between 0 and 1 will give an appropriate mix of the + // two. The result of the blend is then returned as an ImageData object... + function blendImageDatas(imageDataOne, imageDataTwo, amountImageDataOne){ + const blendedData = new ImageData(imageDataOne.width, imageDataOne.height); + const amountImageDataTwo = 1 - amountImageDataOne; + for (let i = 0; i < imageDataOne.data.length; i += 4) { + blendedData.data[i ] = (imageDataOne.data[i ] * amountImageDataOne) + (imageDataTwo.data[i ] * amountImageDataTwo); + blendedData.data[i + 1] = (imageDataOne.data[i + 1] * amountImageDataOne) + (imageDataTwo.data[i + 1] * amountImageDataTwo); + blendedData.data[i + 2] = (imageDataOne.data[i + 2] * amountImageDataOne) + (imageDataTwo.data[i + 2] * amountImageDataTwo); + blendedData.data[i + 3] = (imageDataOne.data[i + 3] * amountImageDataOne) + (imageDataTwo.data[i + 3] * amountImageDataTwo); + } + return blendedData; + } - // Calculate the coordinates of the pixel in the old image data that - // corresponds to this element - let x1 = Math.round(oldX + kx); - let y1 = Math.round(oldY + ky); + // First, let's just copy imageData, and work on the copy instead - just in + // case we don't want to modify the input directly (it's passed by reference + // because it's an object - thanks JavaScript!) + let inputData = new ImageData(imageData.width, imageData.height); + inputData.data.set(imageData.data); - // Clamp the coordinates to the valid range - x1 = Math.max(0, Math.min(x1, oldWidth - 1)); - y1 = Math.max(0, Math.min(y1, oldHeight - 1)); + // Now let's reduce the input's width and height (independently) until doing + // so again would take the input's dimensions below the target dimensions; + // we'll use plain-old browser bilinear filtering to do this... + while (newWidth <= Math.round(inputData.width * 0.5)) { + inputData = _bilinear(inputData, Math.round(inputData.width * 0.5), inputData.height); + } + while (newHeight <= Math.round(inputData.height * 0.5)) { + inputData = _bilinear(inputData, inputData.width, Math.round(inputData.height * 0.5)); + } - // Get the index of the pixel in the old pixel data array - let i1 = (y1 * oldWidth + x1) * 4; + // Now, inputData either exactly matches the dimensions of newWidth AND + // newHeight (in which case we're done), OR one or more of inputData's + // dimensions is greater than newWidth/newHeight. In that case, we want to + // generate the next level down for the dimensions that't aren't equal, and + // then blend between inputData (which is too detailed) and the next level + // down (which is too soft) based on how far away from the next level down + // we are - this essentially gives an effect like mip-mapping, and will help + // to ensure that transitions across 50% downscale thresholds aren't too + // jarring... + if (newWidth < inputData.width || newHeight < inputData.height) { - // Get the RGBA values of the pixel in the old pixel data array - let r1 = oldData[i1]; - let g1 = oldData[i1 + 1]; - let b1 = oldData[i1 + 2]; - let a1 = oldData[i1 + 3]; + // OK, one or more of inputData's dimensions is greater than + // newWidth/newHeight, so we're going to be doing some blending. First we + // need to generate the next scale of image downwards - just like before, + // we'll do each dimension separately... + let blendData = new ImageData(inputData.width, inputData.height); + blendData.data.set(inputData.data); + if (newWidth < inputData.width) { + blendData = _bilinear(blendData, Math.round(blendData.width * 0.5), blendData.height); + } + if (newHeight < inputData.height) { + blendData = _bilinear(blendData, blendData.width, Math.round(blendData.height * 0.5)); + } - // Multiply the RGBA values by the kernel value and add them to the - // pixel values - r7 += r1 * w; - g7 += g1 * w; - b7 += b1 * w; - a7 += a1 * w; + // Now we have the next level of downscale, we need to work out where + // our target width and height lie between inputData's width and height, + // and blendData's width and height - that'll tell us what proportion of + // each image should be in the final blend between the two. Once we've + // worked that out, we blend inputData with blendData accordingly + // (blendData gets upscaled to inputData's resolution first, to make the + // blending less complicated)... + let axisBeingBlended = 0; + let widthFactor = 0; + let heightFactor = 0; + if (inputData.width - blendData.width != 0) { + widthFactor = (newWidth - blendData.width) / (inputData.width - blendData.width); + axisBeingBlended += 1; + } + if (inputData.height - blendData.height != 0) { + heightFactor = (newHeight - blendData.height) / (inputData.height - blendData.height); + axisBeingBlended += 1; + } + inputData = blendImageDatas(inputData, _bilinear(blendData, inputData.width, inputData.height), (widthFactor + heightFactor) / axisBeingBlended); + } - // Add the kernel value to the sum - sum += w; + // Finally, return imageData as scaled to the final desired width and height + // (it may not currently exactly those dimensions)... + return _bilinear(inputData, newWidth, newHeight); + } + + // Utility function that takes in an ImageData object, and returns a + // downscaled version using a technique called Hermite interpolation. This + // method is a little slow, produces very high-quality output and works great + // with alpha channels... but it can introduce a little image distortion with + // high-frequency inputs, as well as some light aliasing on very fine details. + // To mitigate this, I've modified it to use a hybrid approach - I scale the + // image image to twice the target width and height using _halveToTarget + // (defined above), even if this means upscaling the input image first, and + // *then* I downscale the result using Hermite interpolation. The result is a + // bit slower again (as the input gets rescaled several times), but produced + // excellent results with zero distortion across all of my test images, even + // those with huge downscale ratios and very fine details (starfields). The + // core of this function is based on the following JSFiddle code, but again + // with some modifications by myself: https://jsfiddle.net/9g9Nv/442/ + function _hermite(imageData, newWidth, newHeight){ + + // So the very first thing we do is rescale the input to twice the desired + // width and height using _halveToTarget and bilinear filtering (which may + // involve upscaling the input), before we use Hermite interpolation to + // downscale to the final target resolution - this hybrid approach appears + // to give excellent results for a slight speed penalty. We just call our + // main scaleImage() function, and force it to use "halveToTarget" instead + // of the default "hermite" method (don't want to be stuck in an infinite + // loop!). We'll do all our work on a copy of the input, just in case + // we don't want to modify that in the calling code... + let inputData = new ImageData(imageData.width, imageData.height); + inputData.data.set(imageData.data); + inputData = scaleImage(inputData, newWidth * 2, newHeight * 2, "Bilinear", "halveToTarget"); + + // OK, now it's on to the main Hermite interpolation; thanks to the original + // author of the JSFiddle! I've just cleaned up their code a little to fit + // within my use-case here. I'm not a "maths" person, and so the + // underpinnings of Hermite interpolation go over my head (I'm talking about + // the Wikipedia article here)... but from reading the code below, what I + // believe is happening is that it loops through the input data, and breaks + // it up into little rectangular chunks, where each chunk will become one + // pixel in the output image. It goes through each pixel of the chunk, and + // accumulates a weighted version of its RGBA data into a buffer (the gr_X + // variables). Once it's finished going through the chunk, it takes the + // accumulation buffer and uses the total weight to calculate a final RGBA + // for the matching pixel in the output buffer and stores it. The bit that's + // over my head is why the weighting is calculated the way it is... I'll + // leave that as an exercise to the more mathematically inclined, and just + // say silent thanks to the JSFiddle author again! I've renamed their + // original variables to match my own understanding as best I can, in case + // it helps to parse it a bit better... + const ratioWidth = inputData.width / newWidth; + const ratioWidthHalf = Math.ceil(ratioWidth / 2); + const ratioHeight = inputData.height / newHeight; + const ratioHeightHalf = Math.ceil(ratioHeight / 2); + const output = new ImageData(newWidth, newHeight); + + // Loop through our desired output (as we're calculating the final value + // of each output pixel directly)... + for (let outputY = 0; outputY < newHeight; outputY++) { + for (let outputX = 0; outputX < newWidth; outputX++) { + + let currentWeight = 0; + let totalWeightRGB = 0; + let totalWeightAlpha = 0; + let accumulatorRed = 0; + let accumulatorGreen = 0; + let accumulatorBlue = 0; + let accumulatorAlpha = 0; + const center_y = (outputY + 0.5) * ratioHeight; + + // Calculate the borders of the "chunk" of input that'll be weighted + // down to a single output pixel... + const inputChunkLeftEdge = Math.floor(outputX * ratioWidth); + const inputChunkRightEdge = Math.min(Math.ceil((outputX + 1) * ratioWidth), inputData.width); + const inputChunkTopEdge = Math.floor(outputY * ratioHeight); + const inputChunkBottomEdge = Math.min(Math.ceil((outputY + 1) * ratioHeight), inputData.height); + + // Now loop through the input rows within that chunk... + for (let inputY = inputChunkTopEdge; inputY < inputChunkBottomEdge; inputY++) { + + // These three lines I'm not sure about, to be honest... + const dy = Math.abs(center_y - (inputY + 0.5)) / ratioHeightHalf; + const center_x = (outputX + 0.5) * ratioWidth; + const w0 = dy * dy; + + // And loop through the input columns within those rows... + for (let inputX = inputChunkLeftEdge; inputX < inputChunkRightEdge; inputX++) { + + // Again, these lines are similar to the three above; I know "w" is + // used in the Hermite weighting calculation... + const dx = Math.abs(center_x - (inputX + 0.5)) / ratioWidthHalf; + const w = Math.sqrt(w0 + dx * dx); + if (w >= 1) { + continue; + } + + // This line is where the Hermite weighting is calculated... + currentWeight = 2 * w * w * w - 3 * w * w + 1; + + // Now we use the weighting to fractions of the source pixel RGBA + // data in our accumulators; we also add the weight of the current + // pixel to a weight accumulator... + const pos_x = 4 * (inputX + inputY * inputData.width); + accumulatorAlpha += currentWeight * inputData.data[pos_x + 3]; + totalWeightAlpha += currentWeight; + if (inputData.data[pos_x + 3] < 255) { + currentWeight = currentWeight * inputData.data[pos_x + 3] / 250; + } + accumulatorRed += currentWeight * inputData.data[pos_x]; + accumulatorGreen += currentWeight * inputData.data[pos_x + 1]; + accumulatorBlue += currentWeight * inputData.data[pos_x + 2]; + totalWeightRGB += currentWeight; } } - // Divide the RGBA values by the sum to get an average value - r7 /= sum; - g7 /= sum; - b7 /= sum; - a7 /= sum; - - // Round the RGBA values to integers - r7 = Math.round(r7); - g7 = Math.round(g7); - b7 = Math.round(b7); - a7 = Math.round(a7); - - // Get the index of the pixel in the new pixel data array - let j = (y * newWidth + x) * 4; - - // Set the RGBA values of the pixel in the new pixel data array - newData[j] = r7; - newData[j + 1] = g7; - newData[j + 2] = b7; - newData[j + 3] = a7; + // Now that we've finished accumulating the weighted RGBA data for that + // entire source "chunk", we divide it by the total weight and store the + // result in our output ImageData object... + const pixelIndex = (outputX + outputY * newWidth) * 4; + output.data[pixelIndex ] = accumulatorRed / totalWeightRGB; + output.data[pixelIndex + 1] = accumulatorGreen / totalWeightRGB; + output.data[pixelIndex + 2] = accumulatorBlue / totalWeightRGB; + output.data[pixelIndex + 3] = accumulatorAlpha / totalWeightAlpha; } } - - // Create and return a new ImageData object with the new pixel data array - // and dimensions - return new ImageData(newData, newWidth, newHeight); + + // And that's it - output now contains the downscaled image, so return it! + return output; } - // Get the original width and height... - var width = input.width; - var height = input.height; - // Before we consider doing *any* scaling, let's check to make sure the new // dimensions are different from the old ones; if they're not, there's no // point in doing any scaling! - if (width == newWidth && height == newHeight) { + if (input.width == newWidth && input.height == newHeight) { return input; } @@ -659,43 +843,33 @@ function scaleImage(input, newWidth, newHeight, method) { // If the method is "Nearest Neighbour"... case "Nearest Neighbour": - // Create a new canvas element to draw the scaled image (we'll use the - // canvas to get our output ImageData object)... - var canvas = document.createElement("canvas"); - var context = canvas.getContext("2d"); - - // Set the canvas size to the new dimensions... - canvas.width = newWidth; - canvas.height = newHeight; - - // Create a new image data object to store the scaled pixel data... - var scaledData = context.createImageData(newWidth, newHeight); + // Create a new ImageData object to store the scaled pixel data... + const outputData = new ImageData(newWidth, newHeight); // Loop through each pixel of the new image... - for (var y = 0; y < newHeight; y++) { - for (var x = 0; x < newWidth; x++) { + for (let outputY = 0; outputY < newHeight; outputY++) { + for (let outputX = 0; outputX < newWidth; outputX++) { - // Calculate the index of the new pixel in the scaled data array... - var index = (y * newWidth + x) * 4; + // Calculate the index of the new pixel in the output data... + const outputIndex = (outputY * newWidth + outputX) * 4; // Calculate the x and y coordinates of the corresponding pixel in - // the original image... - var x2 = Math.floor(x * width / newWidth); - var y2 = Math.floor(y * height / newHeight); + // the original image, and it's index within input's data... + const inputX = Math.floor(outputX * input.width / newWidth); + const inputY = Math.floor(outputY * input.height / newHeight); + const inputIndex = (inputY * input.width + inputX) * 4; - // Calculate the index of the original pixel in the data array... - var index2 = (y2 * width + x2) * 4; - - // Copy the color values from the original pixel to the new pixel... - scaledData.data[index] = input.data[index2]; // Red - scaledData.data[index + 1] = input.data[index2 + 1]; // Green - scaledData.data[index + 2] = input.data[index2 + 2]; // Blue - scaledData.data[index + 3] = input.data[index2 + 3]; // Alpha + // Copy the color values from the input pixel to the output pixel... + outputData.data[outputIndex] = input.data[inputIndex]; // Red + outputData.data[outputIndex + 1] = input.data[inputIndex + 1]; // Green + outputData.data[outputIndex + 2] = input.data[inputIndex + 2]; // Blue + outputData.data[outputIndex + 3] = input.data[inputIndex + 3]; // Alpha } } // Finally, return the scaled ImageData object... - return scaledData; + return outputData; + break; // If the method is "Bilinear"... case "Bilinear": @@ -704,66 +878,73 @@ function scaleImage(input, newWidth, newHeight, method) { // you're *upscaling* an image, but if you're *downscaling* an image // by more than half the original's width/height, then true bilinear // filtering creates just as much of an aliased mess as nearest - // neighbour filtering. Most image editing apps therefore cheat and - // use a resampling algorithm when downscaling, and bilinear filtering - // when upscaling... so that's what we're going to do here too! We'll - // use gaussian resampling for any image axis that's being downscaled, - // and bilinear for any axis that's being upscaled; this should give - // the user a result that's much closer to what they'd expect to see... + // neighbour filtering once you get down to downscaling by more than half + // the dimensions of the original image. Most image editing apps therefore + // cheat and use a resampling algorithm when downscaling, and bilinear + // filtering when upscaling... so that's what we're going to do here too! + // We'll use a hybrid of bilinear and Hermite interpolation for any image + // axis that's being downscaled, and bilinear for any axis that's being + // upscaled; this should give the user a result that's much closer to what + // they'd expect to see from a graphics app like Photoshop or similar... + let upscaling = false, downscaling = false; + if (newWidth > input.width || newHeight > input.height) { upscaling = true; } + if (newWidth < input.width || newHeight < input.height) { downscaling = true; } - // Let's see which kind of scaling scenario we're in... - if (newWidth > width && newHeight > height) { - - // All dimensions being upscaled, so we'll use bilinear filtering for - // everything... + // Now we'll process the image differently depending on whether or not + // we're only upscaling, only downscaling, or doing a mix of upscaling and + // downscaling. In the case of a mix, we do the upscaling part first, and + // the downscaling part second, as it'll give a slightly sharper result... + if (upscaling && !downscaling) { + // Upscale only... return _bilinear(input, newWidth, newHeight); } - else if (newWidth < width && newHeight < height) { - - // All dimensions being downscaled, so we'll use gaussian resampling - // for everything... - return _gaussian(input, newWidth, newHeight); + else if (downscaling && !upscaling) { + // Downscale only - run the input through either our halve-to-target or + // Hermite filter... + switch (downscaleFilter){ + case "hermite": + return _hermite(input, newWidth, newHeight); + break; + case "halveToTarget": + return _halveToTarget(input, newWidth, newHeight); + break; + } } else { - - // It's a mix! - if (newWidth < width) { - - // Gaussian for width, bilinear for height... - let partial = _gaussian(input, newWidth, height); - return _bilinear(partial, newWidth, newHeight); + // Both upscaling and downscaling... do the upscale first, then send the + // result back to this function again for downscaling... + let partiallyScaled; + if (newWidth > input.width) { + // Upscale width + partiallyScaled = _bilinear(input, newWidth, input.height); } - else if (newHeight < height) { - - // Gaussian for height, bilinear for width... - let partial = _gaussian(input, width, newHeight); - return _bilinear(partial, newWidth, newHeight); + else { + // Upscale height + partiallyScaled = _bilinear(input, input.width, newHeight); } + // Downscale the rest... + return scaleImage(partiallyScaled, newWidth, newHeight, method, downscaleFilter); } break; } } // This utility function is used for adding info, warning and error messages to -// the appropriate spots in my tools. It uses custom-modified versions of the -// SVG Famfamfam Silk icon set via https://github.com/frhun/silk-icon-scalable +// the appropriate spots in my tools... function setMessage(type, divID, text) { - var icon; + let icon = ""; switch(type) { case "info": - icon = ''; - break; + icon = "ℹī¸"; + break; case "warning": - icon = ''; - break; + icon = "⚠ī¸"; + break; case "error": - icon = ''; - break; + icon = "🛑"; + break; } - document.getElementById(divID).innerHTML = "

" + icon + " " + text + "

"; - - return; } \ No newline at end of file