image-corruptor
Version:
8Crafter's Image Corruptor. This CLI/module allows you to add corrupted effects to your images.
572 lines (555 loc) • 23.7 kB
text/typescript
import { Canvas, CanvasRenderingContext2D, createCanvas, Image, ImageData, loadImage } from "canvas";
/**
* The version of the program.
*/
export const format_version = "1.0.0-alpha.5";
interface ImageCorruptorOptions {
/**
* The chance of a pixel being replaced.
*
* Must be between 0 and 1 (inclusive)
*
* Does not apply to the following modes:
* - `deepfry`
*
* @type {number}
*
* @default 0.1
*/
replaceChance?: number;
/**
* If true, then when a pixel is replaced, it will start with the new color being the current color, instead of black.
* For example when using `randomColorFullBrightnessOneChannel` mode, the new color will be the current color, with one channel set to the maximum value.
*
* Only applies to the following modes:
* - `randomColorFullBrightness`
* - `randomColorFullBrightnessOneChannel`
* - `randomColorFullBrightnessOneOrTwoChannels`
* - `randomColorFullBrightnessTwoChannels`
*
* @type {boolean}
*
* @default false
*/
useCurrentColorAsDefault?: boolean;
/**
* If true, then when a pixel is replaced, its alpha channel is preserved.
*
* Does not apply to the following modes:
* - `erase`
* - `setToWhite`
* - `setToBlack`
* - `deepfry`
*
* @type {boolean}
*
* @default false
*/
preserveAlpha?: boolean;
/**
* If true, then pixels with all channels being 0 are not courrupted.
*
* Does not apply to the following modes:
* - `deepfry`
*
* @type {boolean}
*
* @default false
*/
ignoreEmptyPixels?: boolean;
/**
* If true, then pixels with an alpha channel of 0 are not courrupted.
*
* Does not apply to the following modes:
* - `deepfry
*
* @type {boolean}
*
* @default false
*/
ignoreInvisiblePixels?: boolean;
/**
* The X and Y scales of the pixels, this specifies the size of each pixel,
* setting this to a larger value may reduce file size, this will not affect
* the width or height of the image. If not provided, the scales will both be 1.
*
* Does not apply to the following modes:
* - `deepfry
*
* @type {[x?: number | undefined, y?: number | undefined]}
*
* @default
* ```js
* [1, 1]
* ```
*/
scale?: [x?: number | undefined, y?: number | undefined];
/**
* The contrast to use for "deepfry" mode. Should be a float, there are no range restrictions.
*
* A higher number will result in a more deepfried image.
*
* Only applies to the following modes:
* - `deepfry`
*
* @type {number}
*
* @default 0.5
*/
deepfryContrast?: number;
/**
* How many times to convert the image to a really low quality JPEG for "deepfry" mode, should be an integer greater than or equal to 0.
*
* A higher number will result in a more deepfried image.
*
* Only applies to the following modes:
* - `deepfry`
*
* @type {number}
*
* @default 10
*/
deepfryQualityDamage?: number;
/**
* The quality to use for converting the image to a really low quality JPEG for "deepfry" mode, should be a float between 0 and 1 (inclusive).
*
* A lower number will result in a more deepfried image.
*
* Only applies to the following modes:
* - `deepfry`
*
* @type {number}
*
* @default 0.25
*/
deepfryDamagingQuality?: number;
/**
* The mode to use.
*
* Options:
* - `randomColor` (default) - Replaces the pixel with a random color.
* - `randomColorFullBrightness` - Replaces the red, green, and blue channels with 0 or 255, each with a 50% chance. ex. `rgba(255, 0, 0, 1)`, `rgba(255, 255, 0, 1)`, `rgba(0, 255, 255, 1)`, or `rgba(255, 255, 255, 1)`.
* - `randomColorFullBrightnessOneChannel` - If {@link useCurrentColorAsDefault} is true, then the new color will be the current color, with one channel set to the maximum value. Otherwise, it will be #FF0000, #00FF00, or #0000FF.
* - `randomColorFullBrightnessRedChannel` - Replaces the red channel with 0 or 255.
* - `randomColorFullBrightnessGreenChannel` - Replaces the green channel with 0 or 255.
* - `randomColorFullBrightnessBlueChannel` - Replaces the blue channel with 0 or 255.
* - `randomColorFullBrightnessOneOrTwoChannels` - If {@link useCurrentColorAsDefault} is true, then the new color will be the current color, with either one or two channels set to the maximum value. Otherwise, it will be #FF0000, #00FF00, #0000FF, #FFFF00, #FF00FF, or #00FFFF.
* - `randomColorFullBrightnessTwoChannels` - If {@link useCurrentColorAsDefault} is true, then the new color will be the current color, with two channels set to the maximum value. Otherwise, it will be #FFFF00 #00FFFF, or #FF00FF.
* - `erase` - Erases the pixel.
* - `setToWhite` - Replaces the pixel with white.
* - `setToBlack` - Replaces the pixel with black.
* - `invert` - Inverts the pixel.
* - `deepfry` - Deepfries the entire image. This increases contrast and lowers the quality of the image, resulting in the "deep fried" effect.
* - `random` - Uses a random mode for each pixel.
*
* @type {"randomColor" | "randomColorFullBrightness" | "randomColorFullBrightnessOneChannel" | "randomColorFullBrightnessOneOrTwoChannels" | "randomColorFullBrightnessTwoChannels" | "randomColorFullBrightnessRedChannel" | "randomColorFullBrightnessGreenChannel" | "randomColorFullBrightnessBlueChannel" | "erase" | "setToWhite" | "setToBlack" | "invert" | "deepfry" | "random"}
*
* @default "randomColor"
*/
mode?:
| "randomColor"
| "randomColorFullBrightness"
| "randomColorFullBrightnessOneChannel"
| "randomColorFullBrightnessOneOrTwoChannels"
| "randomColorFullBrightnessTwoChannels"
| "randomColorFullBrightnessRedChannel"
| "randomColorFullBrightnessGreenChannel"
| "randomColorFullBrightnessBlueChannel"
| "erase"
| "setToWhite"
| "setToBlack"
| "invert"
| "deepfry"
| "random";
/**
* The format for the output image.
*
* @type {"png" | "jpg" | "jpeg" | "pdf" | "svg"}
*
* @throws {TypeError} If the format is invalid.
* @throws {TypeError} If the format is `pdf`, this is because the PDF format has been disabled due to it causing hangs.
*
* @default "png"
*/
format?: "png" | "jpg" | "jpeg" | "pdf" | "svg";
/**
* The options for the JPEG image, only used if `options.format` is set to `jpg`.
*/
jpegOptions?: {
/**
* Specifies the quality, between 0 and 1.
*
* @default 0.75
*/
quality?: number;
/**
* Enables progressive encoding.
*
* @default false
*/
progressive?: boolean;
/**
* Enables 2x2 chroma subsampling.
*
* @default false
*/
chromaSubsampling?: boolean;
};
}
/**
* The list of valid modes that are not `random`.
*/
export const nonRandomModes = [
"randomColor",
"randomColorFullBrightness",
"randomColorFullBrightnessOneChannel",
"randomColorFullBrightnessOneOrTwoChannels",
"randomColorFullBrightnessTwoChannels",
"erase",
"setToWhite",
"setToBlack",
"invert",
"deepfry",
] as const;
/**
* Applies a contrast effect to the image data.
*
* @param {ImageData} imageData The image data.
* @param {number} val The value to apply, must be between 0 and 1 (inclusive).
* @returns {ImageData} The image data with the contrast applied.
*
* @see {@link https://stackoverflow.com/a/79557593} This is the source of this function.
*/
export function applyContrast(imageData: ImageData, val: number): ImageData {
const d = imageData.data;
val *= 255;
const factor = val === 255 ? 255 : (val + 255) / (255 - val);
for (let i = 0; i < d.length; i += 4) {
d[i + 0] = factor * (d[i + 0] - 128) + 128;
d[i + 1] = factor * (d[i + 1] - 128) + 128;
d[i + 2] = factor * (d[i + 2] - 128) + 128;
}
return imageData;
}
/**
* Corrupts an image.
*
* @param {string | Buffer} src The image to corrupt.
* @param {ImageCorruptorOptions} options The options to use.
* @returns {Promise<Buffer<ArrayBufferLike>>} The corrupted image.
*
* @throws {TypeError} If the format is invalid.
* @throws {TypeError} If the format is `pdf`, this is because the PDF format has been disabled due to it causing hangs.
*/
export async function corruptImage(src: string | Buffer, options: ImageCorruptorOptions = {}): Promise<Buffer<ArrayBufferLike>> {
// Load the image
/**
* The image to corrupt.
*
* @type {Image}
*/
const srcImg: Image = await loadImage(src);
/**
* The X scale of the image.
*
* Get the value from the third non-flag argument.
*
* @type {number}
*/
const scaleX: number = options.scale?.[0] ?? 1;
/**
* The Y scale of the image.
*
* Get the value from the third non-flag argument.
*
* @type {number}
*/
const scaleY: number = options.scale?.[1] ?? 1;
// Check if the X scale is at least 1.
if (scaleX < 1) {
console.error("\u001B[38;2;255;0;0mInvalid X scale, must be at least 1. Use the --help or -h option to see the usage.\u001B[0m");
process.exit(1);
}
// Check if the Y scale is at least 1.
if (scaleY < 1) {
console.error("\u001B[38;2;255;0;0mInvalid Y scale, must be at least 1. Use the --help or -h option to see the usage.\u001B[0m");
process.exit(1);
}
// Instantiate the canvas object
/**
* The canvas object.
*
* @type {Canvas}
*/
const canvas: Canvas = createCanvas(
srcImg.width,
srcImg.height,
options.format?.toLowerCase() === "svg" ? "svg" : options.format?.toLowerCase() === "pdf" ? "pdf" : undefined
);
/**
* The context of the canvas.
*
* @type {CanvasRenderingContext2D}
*/
const context: CanvasRenderingContext2D = canvas.getContext("2d");
/**
* The canvas object.
*
* @type {Canvas}
*/
const canvasB: Canvas = createCanvas(srcImg.width, srcImg.height);
/**
* The context of the canvas.
*
* @type {CanvasRenderingContext2D}
*/
const contextB: CanvasRenderingContext2D = canvasB.getContext("2d");
context.imageSmoothingEnabled = false;
context.drawImage(srcImg, 0, 0);
contextB.imageSmoothingEnabled = false;
contextB.drawImage(srcImg, 0, 0);
if (options.mode === "deepfry") {
const imgData: ImageData = context.getImageData(0, 0, srcImg.width, srcImg.height);
applyContrast(imgData, options.deepfryContrast ?? 0.5);
context.putImageData(imgData, 0, 0);
for (let i = 0; i < (options.deepfryQualityDamage ?? 10); i++) {
const img = await loadImage(
canvas.toBuffer("image/jpeg", {
chromaSubsampling: true,
progressive: false,
quality: options.deepfryDamagingQuality ?? 0.25,
})
);
context.drawImage(img, 0, 0);
}
} else {
for (let x = 0; x < srcImg.width; x++) {
for (let y = 0; y < srcImg.height; y++) {
/**
* The data of the pixel.
*
* @type {Uint8ClampedArray<ArrayBufferLike>}
*/
const data: Uint8ClampedArray<ArrayBufferLike> = contextB.getImageData(x, y, 1, 1).data;
if ((options.ignoreEmptyPixels ?? false) && data.every((v) => v === 0)) continue;
if ((options.ignoreInvisiblePixels ?? false) && data[3] === 0) continue;
if (Math.random() >= (options.replaceChance ?? 0.1)) continue;
/**
* The mode to use.
*
* @type {typeof options.mode}
*/
const newMode: typeof options.mode =
options.mode === "random"
? nonRandomModes.filter((m) => m !== "deepfry")[Math.floor(Math.random() * (nonRandomModes.length - 1))]
: options.mode ?? "randomColor";
switch (newMode) {
case "randomColor": {
const r: number = Math.floor(Math.random() * 256);
const g: number = Math.floor(Math.random() * 256);
const b: number = Math.floor(Math.random() * 256);
context.fillStyle = `rgba(${r},${g},${b},${options.preserveAlpha ?? false ? data[3] / 255 : 1})`;
context.clearRect(x, y, scaleX, scaleY);
context.fillRect(x, y, scaleX, scaleY);
break;
}
case "randomColorFullBrightness": {
const r: number = Math.random() < 0.5 ? (options.useCurrentColorAsDefault ?? false ? data[0] : 0) : 255;
const g: number = Math.random() < 0.5 ? (options.useCurrentColorAsDefault ?? false ? data[1] : 0) : 255;
const b: number = Math.random() < 0.5 ? (options.useCurrentColorAsDefault ?? false ? data[2] : 0) : 255;
context.fillStyle = `rgba(${r},${g},${b},${options.preserveAlpha ?? false ? data[3] / 255 : 1})`;
context.clearRect(x, y, scaleX, scaleY);
context.fillRect(x, y, scaleX, scaleY);
break;
}
case "randomColorFullBrightnessOneChannel": {
let r: number = options.useCurrentColorAsDefault ?? false ? data[0] : 0;
let g: number = options.useCurrentColorAsDefault ?? false ? data[1] : 0;
let b: number = options.useCurrentColorAsDefault ?? false ? data[2] : 0;
switch (Math.floor(Math.random() * 3)) {
case 0: {
r = 255;
break;
}
case 1: {
g = 255;
break;
}
case 2: {
b = 255;
break;
}
}
context.fillStyle = `rgba(${r},${g},${b},${options.preserveAlpha ?? false ? data[3] / 255 : 1})`;
context.clearRect(x, y, scaleX, scaleY);
context.fillRect(x, y, scaleX, scaleY);
break;
}
case "randomColorFullBrightnessOneOrTwoChannels": {
let r: number = options.useCurrentColorAsDefault ?? false ? data[0] : 0;
let g: number = options.useCurrentColorAsDefault ?? false ? data[1] : 0;
let b: number = options.useCurrentColorAsDefault ?? false ? data[2] : 0;
if (Math.random() < 0.5) {
switch (Math.floor(Math.random() * 3)) {
case 0: {
r = 255;
break;
}
case 1: {
g = 255;
break;
}
case 2: {
b = 255;
break;
}
}
} else {
switch (Math.floor(Math.random() * 3)) {
case 0: {
r = 255;
g = 255;
break;
}
case 1: {
g = 255;
b = 255;
break;
}
case 2: {
r = 255;
b = 255;
break;
}
}
}
context.fillStyle = `rgba(${r},${g},${b},${options.preserveAlpha ?? false ? data[3] / 255 : 1})`;
context.clearRect(x, y, scaleX, scaleY);
context.fillRect(x, y, scaleX, scaleY);
break;
}
case "randomColorFullBrightnessTwoChannels":
{
let r: number = options.useCurrentColorAsDefault ?? false ? data[0] : 0;
let g: number = options.useCurrentColorAsDefault ?? false ? data[1] : 0;
let b: number = options.useCurrentColorAsDefault ?? false ? data[2] : 0;
switch (Math.floor(Math.random() * 3)) {
case 0: {
r = 255;
g = 255;
break;
}
case 1: {
g = 255;
b = 255;
break;
}
case 2: {
r = 255;
b = 255;
break;
}
}
context.fillStyle = `rgba(${r},${g},${b},${options.preserveAlpha ?? false ? data[3] / 255 : 1})`;
context.clearRect(x, y, scaleX, scaleY);
context.fillRect(x, y, scaleX, scaleY);
}
break;
case "randomColorFullBrightnessRedChannel": {
const r: number = 255;
const g: number = data[1];
const b: number = data[2];
context.fillStyle = `rgba(${r},${g},${b},${options.preserveAlpha ?? false ? data[3] / 255 : 1})`;
context.clearRect(x, y, scaleX, scaleY);
context.fillRect(x, y, scaleX, scaleY);
break;
}
case "randomColorFullBrightnessGreenChannel": {
const r: number = data[0];
const g: number = 255;
const b: number = data[2];
context.fillStyle = `rgba(${r},${g},${b},${options.preserveAlpha ?? false ? data[3] / 255 : 1})`;
context.clearRect(x, y, scaleX, scaleY);
context.fillRect(x, y, scaleX, scaleY);
break;
}
case "randomColorFullBrightnessBlueChannel": {
const r: number = data[0];
const g: number = data[1];
const b: number = 255;
context.fillStyle = `rgba(${r},${g},${b},${options.preserveAlpha ?? false ? data[3] / 255 : 1})`;
context.clearRect(x, y, scaleX, scaleY);
context.fillRect(x, y, scaleX, scaleY);
break;
}
case "setToWhite": {
context.fillStyle = `rgba(255,255,255,${options.preserveAlpha ?? false ? data[3] / 255 : 1})`;
context.clearRect(x, y, scaleX, scaleY);
context.fillRect(x, y, scaleX, scaleY);
break;
}
case "setToBlack": {
context.fillStyle = `rgba(0,0,0,${options.preserveAlpha ?? false ? data[3] / 255 : 1})`;
context.clearRect(x, y, scaleX, scaleY);
context.fillRect(x, y, scaleX, scaleY);
break;
}
case "erase": {
context.clearRect(x, y, scaleX, scaleY);
break;
}
case "invert": {
context.fillStyle = `rgba(${255 - data[0]},${255 - data[1]},${255 - data[2]},${options.preserveAlpha ?? false ? data[3] / 255 : 1})`;
context.clearRect(x, y, scaleX, scaleY);
context.fillRect(x, y, scaleX, scaleY);
break;
}
}
}
}
}
/**
* The mimeType of the output image.
*
* @type {"image/png" | "image/jpeg" | "application/pdf" | undefined}
*
* @default "image/png"
*/
const mimeType: "image/png" | "image/jpeg" | "application/pdf" | undefined = ["jpg", "jpeg"].includes(options.format!?.toLowerCase())
? "image/jpeg"
: options.format?.toLowerCase() === "pdf"
? "application/pdf"
: options.format?.toLowerCase() === "svg"
? undefined
: "image/png";
let buffer: Buffer<ArrayBufferLike>;
switch (mimeType) {
case "application/pdf": {
throw new Error("PDF support has been disabled due to it causing hangs.");
// buffer = canvas.toBuffer(mimeType);
// break;
}
case "image/png": {
buffer = canvas.toBuffer(mimeType);
break;
}
case "image/jpeg": {
buffer = canvas.toBuffer(mimeType, {
chromaSubsampling: options.jpegOptions?.chromaSubsampling ?? false,
progressive: options.jpegOptions?.progressive ?? false,
quality: options.jpegOptions?.quality ?? 0.75,
});
break;
}
case undefined: {
console.warn(
"WARNING!: Using the SVG format should currently be avoided as there is a bug where it makes the SVG image EXRTEMELY large (as in 100 MiB as opposed to 2 MiB)."
);
buffer = canvas.toBuffer();
break;
}
default:
throw new Error(`Unknown MIME type: ${mimeType}`);
}
return buffer;
}