UNPKG

image-corruptor

Version:

8Crafter's Image Corruptor. This CLI/module allows you to add corrupted effects to your images.

622 lines (618 loc) 30.3 kB
import { createCanvas, loadImage } from "canvas"; import * as fs from "fs"; import path from "path"; import ProgressBar from "progress"; import { applyContrast, nonRandomModes } from "./exports.js"; /** * The version of the program. */ export const format_version = "1.0.0-alpha.5"; /** * The arguments passed to the CLI. * * The {@link process.argv} array without the first two elements (node and the script name). * * @type {string[]} */ export const args = process.argv.slice(2); /** * The arguments that are not flags. * * The {@link args} array without any of the strings that started with a `-`, and without the subcommand name. * * @type {string[]} */ export const nonFlagArgs = args.slice(1).filter((arg) => !arg.startsWith("-")); switch (args[0]?.toLowerCase()) { case "version": versionCommand(); break; case "-h": case "--help": case "help": helpCommand(); break; case "corrupt": await corruptCommand(); break; case undefined: console.error("\u001B[38;2;255;0;0mNo subcommand provided. Use the help subcommand to see the usage.\u001B[0m"); process.exit(1); default: console.error("\u001B[38;2;255;0;0mInvalid subcommand: " + args[0] + ". Use the help subcommand to see the usage.\u001B[0m"); process.exit(1); } export function helpCommand() { // Print the help message. console.log(`Usage: image-corruptor corrupt [options] [globDir] [sourceGlob] [sourceExcludeGlob] Generate a random image of the specified dimensions. image-corruptor version Shows the version number. image-corruptor help Show this help message. image-corruptor -h Show this help message. image-corruptor --help Show this help message. Options: -o, --out=<directory> The directory to place the corrupted images in. Default is "./corruptedImages". -s, --scale=<scale> The scale 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 scale will be 1. Format should be "scaleX:scaleY" or "scale". -rc, --replace-chance=<chance> Set the chance of replacing a pixel with a random pixel, should be a float between 0 and 1 (inclusive). Default is 0.1. -ie, --ignore-empty Do not corrupt pixels that have all channels set to 0. -ii, --ignore-invisible Do not corrupt pixels that have the alpha channel set to 0. -ucd, --use-current-as-default Use the current pixel color as the default color for corrupted pixels (instead of black). -m, --mode=<mode> Set the mode to use when corrupting pixels, see the "Modes" section for more information. -pa, --preserve-alpha Preserve the alpha channel of each pixel. -jpg, -jpeg Output the image in JPEG format instead of PNG format. -pdf (DISABLED) Output the image in PDF format instead of PNG format (this option has been disabled due to it causing hangs). -svg (DO NOT USE) Output the image in SVG format instead of PNG format (WARNING: This option should NOT be used, as there is currently a bug where is makes the SVG image EXRTEMELY large (as in 100 MiB as opposed to 2 MiB)). --chroma-subsampling Enable chroma subsampling for JPEG images. --progressive Enable progressive encoding for JPEG images. --quality=<quality> Set the quality of the JPEG image, should be a float between 0 and 1 (inclusive). Default is 0.75. -dc, --df-contrast=<contrast> The contrast to use for "deepfry" mode. Should be a float, there are no range restrictions. Default is 0.5. -dqd, --df-qual-dmg=<qualityDmg> 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. Default is 10. -dq, --df-qual=<quality> 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). Default is 0.25. Paramters: [globDir] The directory to search for source images that match the glob pattern. Default is "./". [sourceGlob] The glob pattern for the source images to corrupt, must be a valid JSON string, if it includes spaces, it must be wrapped in quotes. Default is ["**/*.png", "**/*.jpg", "**/*.jpeg", "**/*.gif"]. [sourceExcludeGlob] The glob pattern for the source images to exclude, must be a valid JSON string, if it includes spaces, it must be wrapped in quotes. Default is ["**/node_modules/**", "**/corruptedImages/**"]. Modes: 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 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 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 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.`); process.exit(0); } export async function corruptCommand() { /** * The type of the canvas. * * @type {"pdf" | "svg" | undefined} */ let type = undefined; /** * The format of the image file. * * @type {"image/png" | "image/jpeg" | "application/pdf" | undefined} */ let format = "image/png"; if (args.includes("-pdf")) { type = "pdf"; format = "application/pdf"; } else if (args.includes("-svg")) { type = "svg"; format = undefined; } else if (args.includes("-jpg") || args.includes("-jpeg")) { format = "image/jpeg"; } const rawScale = args.find((arg) => arg.startsWith("-s=") || arg.startsWith("--scale="))?.split("=")[1] ?? "1"; /** * The X scale of the image. * * @type {number} */ const scaleX = /^[0-9]+$/.test(rawScale) ? Number(rawScale) : Number(rawScale.match(/^x?([0-9]+)[:,\\\/;\-&|xy]([0-9]+)y?$/)?.[1] ?? 1); /** * The Y scale of the image. * * @type {number} */ const scaleY = /^[0-9]+$/.test(rawScale) ? Number(rawScale) : Number(rawScale.match(/^x?[0-9]+[:,\\\/;\-&|xy]([0-9]+)y?$/)?.[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); } // Configuration /** * The glob patterns for the source images to corrupt. * * @type {string[]} * * @default * ```js * ["**\/*.png", "**\/*.jpg", "**\/*.jpeg", "**\/*.gif"] * ``` */ const sourceImageGlobs = nonFlagArgs.length > 1 ? JSON.parse(nonFlagArgs[1]) : ["**/*.png", "**/*.jpg", "**/*.jpeg", "**/*.gif"]; if (!(sourceImageGlobs instanceof Array)) { console.error("[sourceGlob] must be a stringified JSON array."); process.exit(1); } if (sourceImageGlobs.some((glob) => typeof glob !== "string")) { console.error("[sourceGlob] must only include strings."); process.exit(1); } /** * The glob patterns for the source images to exclude. * * @type {string[]} * * @default * ```js * ["**\/node_modules/**", "**\/corruptedImages/**"] * ``` */ const sourceImageExcludeGlobs = nonFlagArgs.length > 2 ? JSON.parse(nonFlagArgs[2]) : ["**/node_modules/**", "**/corruptedImages/**"]; if (!(sourceImageExcludeGlobs instanceof Array)) { console.error("[sourceExcludeGlob] must be a stringified JSON array."); process.exit(1); } if (sourceImageExcludeGlobs.some((glob) => typeof glob !== "string")) { console.error("[sourceExcludeGlob] must only include strings."); process.exit(1); } /** * The directory to search for source images that match the glob pattern. * * @type {string} * * @default "./" */ const globDir = nonFlagArgs.length > 0 ? nonFlagArgs[0] : "./"; /** * The directory to search for source images that match the glob pattern. * * @type {string} * * @default "./corruptedImages" */ const outDir = args.find((arg) => arg.startsWith("-o=") || arg.startsWith("--out="))?.split("=")[1] ?? "./corruptedImages"; /** * The chance of a pixel being replaced. * * Must be between 0 and 1 (inclusive) * * @type {number} * * @default 0.1 */ const replaceChance = Number(args.find((arg) => arg.startsWith("-rc=") || arg.startsWith("--replace-chance="))?.split("=")[1] ?? 0.1); /** * If true, then pixels with all channels being 0 are not courrupted. * * @type {boolean} * * @default false */ const ignoreEmptyPixels = args.includes("-ie") || args.includes("--ignore-empty"); /** * If true, then pixels with an alpha channel of 0 are not courrupted. * * @type {boolean} * * @default false */ const ignoreInvisiblePixels = args.includes("-ii") || args.includes("--ignore-invisible"); /** * 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 */ const useCurrentColorAsDefault = args.includes("-ucd") || args.includes("--use-current-as-default"); /** * If true, then when a pixel is replaced, its alpha channel is preserved. * * Does not apply to the following modes: * - `erase` * - `setToWhite` * - `setToBlack` * * @type {boolean} * * @default false */ const preserveAlpha = args.includes("-pa") || args.includes("--preserve-alpha"); /** * The contrast to use for "deepfry" mode. Should be a float, there are no range restrictions. * * Only applies to the following modes: * - `deepfry` * * @type {number} * * @default 0.5 */ const deepfryContrast = Number(args.find((arg) => arg.startsWith("-dc=") || arg.startsWith("--df-contrast="))?.split("=")[1] ?? 0.5); /** * 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. * * Only applies to the following modes: * - `deepfry` * * @type {number} * * @default 10 */ const deepfryQualityDamage = Number(args.find((arg) => arg.startsWith("-dqd=") || arg.startsWith("--df-qual-dmg="))?.split("=")[1] ?? 10); /** * 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 */ const deepfryDamagingQuality = Number(args.find((arg) => arg.startsWith("-dq=") || arg.startsWith("--df-qual="))?.split("=")[1] ?? 0.25); /** * 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" | "random"} * * @default "randomColor" */ let mode = args.find((arg) => arg.startsWith("-m=") || arg.startsWith("--mode="))?.split("=")[1] ?? "randomColor"; /** * The working directory to use for globbing. * * @type {string} */ const globWorkingDirectory = path.join(process.cwd(), path.relative("./", globDir)); // # DEBUG // console.log(globWorkingDirectory); /** * The source images to corrupt. * * @type {string[]} */ const sourceImages = [...new Set(sourceImageGlobs.map((v) => fs.globSync(v, { cwd: globWorkingDirectory })).flat())] .filter(sourceImageExcludeGlobs.length === 0 ? (v) => true : (v) => !sourceImageExcludeGlobs.some((g) => path.matchesGlob(v, g))) .map((v) => path.join(globWorkingDirectory, v)); if (sourceImages.length === 0) { console.error("No source images found."); process.exit(1); } /** * The progress bar. * * @type {ProgressBar} */ const bar = new ProgressBar("-> Corrupting :bar :percent (:current/:total); :rate/fps; ETA: :etas; Time elapsed: :elapseds", { total: sourceImages.length, width: 30, complete: "\u001B[48;2;0;255;0m \u001B[0m", incomplete: "\u001B[48;5;0m \u001B[0m", }); // This is to keep the type as a union type instead of a string literal. // @ts-ignore mode = mode === "random" ? "random" + "" : mode; for (const sourceImage of sourceImages) { // Load the image /** * The image to corrupt. * * @type {Image} */ const srcImg = await loadImage(sourceImage); // Instantiate the canvas object /** * The canvas object. * * @type {Canvas} */ const canvas = createCanvas(srcImg.width, srcImg.height, type); /** * The context of the canvas. * * @type {CanvasRenderingContext2D} */ const context = canvas.getContext("2d"); context.imageSmoothingEnabled = false; context.drawImage(srcImg, 0, 0); if (mode === "deepfry") { const imgData = context.getImageData(0, 0, srcImg.width, srcImg.height); applyContrast(imgData, deepfryContrast); context.putImageData(imgData, 0, 0); for (let i = 0; i < deepfryQualityDamage; i++) { const img = await loadImage(canvas.toBuffer("image/jpeg", { chromaSubsampling: true, progressive: false, quality: deepfryDamagingQuality, })); context.drawImage(img, 0, 0); } } else { for (let x = 0; x < srcImg.width; x += scaleX) { for (let y = 0; y < srcImg.height; y += scaleY) { /** * The data of the pixel. * * @type {Uint8ClampedArray<ArrayBufferLike>} */ const data = context.getImageData(x, y, 1, 1).data; if (ignoreEmptyPixels && data.every((v) => v === 0)) continue; if (ignoreInvisiblePixels && data[3] === 0) continue; if (Math.random() >= replaceChance) continue; /** * The mode to use. * * @type {typeof mode} */ const newMode = mode === "random" ? nonRandomModes.filter((m) => m !== "deepfry")[Math.floor(Math.random() * (nonRandomModes.length - 1))] : mode; switch (newMode) { case "randomColor": { const r = Math.floor(Math.random() * 256); const g = Math.floor(Math.random() * 256); const b = Math.floor(Math.random() * 256); context.fillStyle = `rgba(${r},${g},${b},${preserveAlpha ? data[3] / 255 : 1})`; context.clearRect(x, y, scaleX, scaleY); context.fillRect(x, y, scaleX, scaleY); break; } case "randomColorFullBrightness": { const r = Math.random() < 0.5 ? (useCurrentColorAsDefault ? data[0] : 0) : 255; const g = Math.random() < 0.5 ? (useCurrentColorAsDefault ? data[1] : 0) : 255; const b = Math.random() < 0.5 ? (useCurrentColorAsDefault ? data[2] : 0) : 255; context.fillStyle = `rgba(${r},${g},${b},${preserveAlpha ? data[3] / 255 : 1})`; context.clearRect(x, y, scaleX, scaleY); context.fillRect(x, y, scaleX, scaleY); break; } case "randomColorFullBrightnessOneChannel": { let r = useCurrentColorAsDefault ? data[0] : 0; let g = useCurrentColorAsDefault ? data[1] : 0; let b = useCurrentColorAsDefault ? 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},${preserveAlpha ? data[3] / 255 : 1})`; context.clearRect(x, y, scaleX, scaleY); context.fillRect(x, y, scaleX, scaleY); break; } case "randomColorFullBrightnessOneOrTwoChannels": { let r = useCurrentColorAsDefault ? data[0] : 0; let g = useCurrentColorAsDefault ? data[1] : 0; let b = useCurrentColorAsDefault ? 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},${preserveAlpha ? data[3] / 255 : 1})`; context.clearRect(x, y, scaleX, scaleY); context.fillRect(x, y, scaleX, scaleY); break; } case "randomColorFullBrightnessTwoChannels": { let r = useCurrentColorAsDefault ? data[0] : 0; let g = useCurrentColorAsDefault ? data[1] : 0; let b = useCurrentColorAsDefault ? 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},${preserveAlpha ? data[3] / 255 : 1})`; context.clearRect(x, y, scaleX, scaleY); context.fillRect(x, y, scaleX, scaleY); } break; case "randomColorFullBrightnessRedChannel": { const r = 255; const g = data[1]; const b = data[2]; context.fillStyle = `rgba(${r},${g},${b},${preserveAlpha ? data[3] / 255 : 1})`; context.clearRect(x, y, scaleX, scaleY); context.fillRect(x, y, scaleX, scaleY); break; } case "randomColorFullBrightnessGreenChannel": { const r = data[0]; const g = 255; const b = data[2]; context.fillStyle = `rgba(${r},${g},${b},${preserveAlpha ? data[3] / 255 : 1})`; context.clearRect(x, y, scaleX, scaleY); context.fillRect(x, y, scaleX, scaleY); break; } case "randomColorFullBrightnessBlueChannel": { const r = data[0]; const g = data[1]; const b = 255; context.fillStyle = `rgba(${r},${g},${b},${preserveAlpha ? 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,${preserveAlpha ? 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,${preserveAlpha ? 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]},${preserveAlpha ? data[3] / 255 : 1})`; context.clearRect(x, y, scaleX, scaleY); context.fillRect(x, y, scaleX, scaleY); break; } } } } } /** * The output directory of the corrupted image. * * @type {string} */ const imageOutDir = path.join(outDir, path.relative(globWorkingDirectory, path.dirname(sourceImage))); /** * The output path of the corrupted image. * * @type {string} */ const imageOutPath = path.join(imageOutDir, `${path.parse(path.basename(sourceImage)).name}${path.parse(path.basename(sourceImage)).ext}` // `${path.parse(path.basename(sourceImage)).name}-corrupted_${replaceChance}-${Date.now()}${path.parse(path.basename(sourceImage)).ext}` ); /** * The output buffer of the corrupted image. * * @type {Buffer<ArrayBufferLike>} */ let buffer; switch (format) { case "application/pdf": throw new Error("PDF support has been disabled due to it causing hangs."); // buffer = canvas.toBuffer(format); // break; case "image/png": buffer = canvas.toBuffer(format); break; case "image/jpeg": buffer = canvas.toBuffer(format, { chromaSubsampling: args.includes("--chroma-subsampling"), progressive: args.includes("--progressive"), quality: Number(args.find((arg) => arg.startsWith("--quality="))?.split("=")[1] ?? 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: ${format}`); } fs.mkdirSync(imageOutDir, { recursive: true }); fs.writeFileSync(imageOutPath, buffer); bar.tick(); } } /** * Prints the version number. */ export function versionCommand() { // Print the version number. console.log(`image-corruptor v${format_version}`); } //# sourceMappingURL=image-corruptor.js.map