A Pen by Luis Alberto Martinez Riancho on CodePen.
Created
March 27, 2026 21:04
-
-
Save kazzohikaru/2dac03b0a7051eade46aa7dd876c261c to your computer and use it in GitHub Desktop.
Glitchify Image v4 [Update 17/04/25]
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <canvas id="output"></canvas> | |
| <div class="resources-layer"> | |
| <div class="resources"> | |
| <a href="https://www.lessrain.com">Less Rain GmbH</a> | |
| <a href="https://codepen.io/collection/bNyZkZ">JavaScript Codepen Collection</a> | |
| </div> | |
| </div> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { Pane, FolderApi } from "https://cdn.skypack.dev/tweakpane@4.0.4"; | |
| const mimeTypeUtils = { | |
| toShortFormat(mimeType) { | |
| return mimeType.replace("image/", ""); | |
| }, | |
| shortToMime(short) { | |
| return `image/${short}`; | |
| }, | |
| } | |
| const CSSUtils = { | |
| getCssVariableValue(el, varName, parseAsNumber = false) { | |
| const computedStyle = getComputedStyle(el); | |
| const value = computedStyle.getPropertyValue(varName)?.trim() || ""; | |
| if (!parseAsNumber) return value; | |
| const match = value.match(/^([\d.]+)(px|%|em|rem|vw|vh|vmin|vmax)?$/); | |
| if (!match) return 0; | |
| const numericValue = parseFloat(match[1]); | |
| const unit = match[2] || "px"; | |
| switch (unit) { | |
| case "em": | |
| return numericValue * parseFloat(computedStyle.fontSize); | |
| case "rem": | |
| return numericValue * parseFloat(getComputedStyle(document.documentElement).fontSize); | |
| case "%": | |
| // Get parent dimensions for percentage context | |
| const parent = el.parentElement; | |
| if (!parent) return 0; | |
| const isWidthContext = ["width", "left", "right", "margin", "padding"].some(prop => | |
| computedStyle.getPropertyValue(prop).includes(varName) | |
| ); | |
| const parentSize = isWidthContext ? | |
| parent.offsetWidth : | |
| parent.offsetHeight; | |
| return (numericValue / 100) * parentSize; | |
| case "vw": | |
| return (numericValue / 100) * window.innerWidth; | |
| case "vh": | |
| return (numericValue / 100) * window.innerHeight; | |
| case "vmin": | |
| return (numericValue / 100) * Math.min(window.innerWidth, window.innerHeight); | |
| case "vmax": | |
| return (numericValue / 100) * Math.max(window.innerWidth, window.innerHeight); | |
| default: // px | |
| return numericValue; | |
| } | |
| }, | |
| }; | |
| // https://codepen.io/luis-lessrain/pen/EaxyJdY | |
| const TweakpaneUtils = { | |
| appendToFolderContent(folder, elements) { | |
| const checkAndAppend = () => { | |
| const folderContent = folder.element.querySelector(".tp-fldv_c"); | |
| if (!folderContent) return false; | |
| (Array.isArray(elements) ? elements : [elements]).forEach((el) => { | |
| if (!folderContent.contains(el)) { | |
| folderContent.appendChild(el); | |
| } | |
| }); | |
| return true; | |
| }; | |
| if (checkAndAppend()) return; | |
| const observer = new MutationObserver(() => { | |
| if (checkAndAppend()) { | |
| observer.disconnect(); | |
| } | |
| }); | |
| observer.observe(folder.element, { childList: true, subtree: true }); | |
| }, | |
| appendToRootPaneContent(pane, elements) { | |
| const checkAndAppend = () => { | |
| const rootContent = pane.element.querySelector(".tp-rotv_c"); | |
| if (!rootContent) return false; | |
| (Array.isArray(elements) ? elements : [elements]).forEach((el) => { | |
| if (!rootContent.contains(el)) { | |
| rootContent.appendChild(el); | |
| } | |
| }); | |
| return true; | |
| }; | |
| if (checkAndAppend()) return; | |
| const observer = new MutationObserver(() => { | |
| if (checkAndAppend()) { | |
| observer.disconnect(); | |
| } | |
| }); | |
| observer.observe(pane.element, { childList: true, subtree: true }); | |
| }, | |
| enableAccordion(pane, targetTitles = null, defaultOpen = null) { | |
| const folders = new Map(); | |
| function isTarget(folder) { | |
| return !targetTitles || targetTitles.length === 0 || targetTitles.includes(folder.title); | |
| } | |
| function registerFolder(folder) { | |
| if (!(folder instanceof FolderApi)) return; | |
| if (!isTarget(folder)) return; | |
| folders.set(folder.title, folder); | |
| const observer = new MutationObserver(() => { | |
| if (folder.expanded) { | |
| folders.forEach((f) => { | |
| if (f !== folder) f.expanded = false; | |
| }); | |
| } | |
| }); | |
| observer.observe(folder.element, { | |
| attributes: true, | |
| attributeFilter: ["class"] | |
| }); | |
| folder.__observer = observer; | |
| } | |
| pane.children | |
| .filter((child) => child instanceof FolderApi) | |
| .forEach(registerFolder); | |
| pane.on("folder:add", (folder) => { | |
| registerFolder(folder); | |
| }); | |
| folders.forEach((folder, title) => { | |
| folder.expanded = title === defaultOpen; | |
| }); | |
| }, | |
| addImageUploader(container, options = {}) { | |
| const { | |
| allowedUploadTypes = ["image/png", "image/jpeg", "image/webp"], | |
| buttonOptions = { title: "Upload Image" }, | |
| onStart = null, | |
| onFinish = null, | |
| onError = null, | |
| onCancel = null, | |
| } = options; | |
| const fileInput = document.createElement("input"); | |
| fileInput.type = "file"; | |
| fileInput.accept = allowedUploadTypes.join(","); | |
| fileInput.style.display = "none"; | |
| fileInput.addEventListener("change", (event) => { | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| if (!allowedUploadTypes.includes(file.type)) { | |
| const errorMsg = `Unsupported file type: ${file.type}`; | |
| console.error(errorMsg); | |
| if (typeof onError === "function") onError(errorMsg, file); | |
| return; | |
| } | |
| if (typeof onStart === "function") onStart(file); | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| const result = e.target.result; | |
| try { | |
| if (typeof onFinish === "function") onFinish(file, result); | |
| } catch (err) { | |
| if (typeof onError === "function") onError(err, file); | |
| else console.error("Error in onFinish:", err); | |
| } | |
| }; | |
| reader.onerror = (e) => { | |
| const error = e?.target?.error || new Error("Unknown FileReader error"); | |
| if (typeof onError === "function") onError(error, file); | |
| }; | |
| reader.readAsDataURL(file); | |
| }); | |
| const uploadButton = container.addButton(buttonOptions); | |
| uploadButton.on("click", () => { | |
| // --- Cancel handling --- | |
| let fileSelected = false; | |
| const onFileChange = () => { | |
| fileSelected = true; | |
| window.removeEventListener("focus", onFocus); | |
| }; | |
| const onFocus = () => { | |
| setTimeout(() => { | |
| if (!fileSelected && typeof onCancel === "function") { | |
| onCancel(); | |
| } | |
| }, 200); | |
| fileInput.removeEventListener("change", onFileChange); | |
| }; | |
| window.addEventListener("focus", onFocus, { once: true }); | |
| fileInput.addEventListener("change", onFileChange, { once: true }); | |
| fileInput.click(); | |
| }); | |
| const isFolder = container.controller?.view?.element.classList.contains("tp-fldv"); | |
| if (isFolder) { | |
| TweakpaneUtils.appendToFolderContent(container, fileInput); | |
| } else { | |
| TweakpaneUtils.appendToRootPaneContent(container, fileInput); | |
| } | |
| return { | |
| button: uploadButton, | |
| fileInput, | |
| openFileDialog: () => fileInput.click(), | |
| }; | |
| }, | |
| async createZipWithFile(blob, filename) { | |
| const arrayBuffer = await blob.arrayBuffer(); | |
| const data = new Uint8Array(arrayBuffer); | |
| const fileNameBytes = new TextEncoder().encode(filename); | |
| const fileNameLength = fileNameBytes.length; | |
| const fileSize = data.length; | |
| const now = new Date(); | |
| const dosTime = ((now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1)) & 0xffff; | |
| const dosDate = (((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate()) & 0xffff; | |
| // --- Local File Header --- | |
| const header = new Uint8Array(30 + fileNameLength); | |
| let offset = 0; | |
| const writeLocalUInt32LE = (value) => { | |
| header[offset++] = value & 0xff; | |
| header[offset++] = (value >> 8) & 0xff; | |
| header[offset++] = (value >> 16) & 0xff; | |
| header[offset++] = (value >> 24) & 0xff; | |
| }; | |
| const writeLocalUInt16LE = (value) => { | |
| header[offset++] = value & 0xff; | |
| header[offset++] = (value >> 8) & 0xff; | |
| }; | |
| writeLocalUInt32LE(0x04034b50); // Local file header signature | |
| writeLocalUInt16LE(20); // Version needed | |
| writeLocalUInt16LE(0); // Flags | |
| writeLocalUInt16LE(0); // Compression: 0 = stored | |
| writeLocalUInt16LE(dosTime); | |
| writeLocalUInt16LE(dosDate); | |
| writeLocalUInt32LE(0); // CRC-32 (ignored) | |
| writeLocalUInt32LE(fileSize); // Compressed size | |
| writeLocalUInt32LE(fileSize); // Uncompressed size | |
| writeLocalUInt16LE(fileNameLength); | |
| writeLocalUInt16LE(0); // Extra field length | |
| header.set(fileNameBytes, offset); | |
| const fileData = new Uint8Array(header.length + fileSize); | |
| fileData.set(header, 0); | |
| fileData.set(data, header.length); | |
| const localHeaderOffset = 0; | |
| // --- Central Directory --- | |
| const centralHeader = new Uint8Array(46 + fileNameLength); | |
| offset = 0; | |
| const writeCentralUInt32LE = (value) => { | |
| centralHeader[offset++] = value & 0xff; | |
| centralHeader[offset++] = (value >> 8) & 0xff; | |
| centralHeader[offset++] = (value >> 16) & 0xff; | |
| centralHeader[offset++] = (value >> 24) & 0xff; | |
| }; | |
| const writeCentralUInt16LE = (value) => { | |
| centralHeader[offset++] = value & 0xff; | |
| centralHeader[offset++] = (value >> 8) & 0xff; | |
| }; | |
| writeCentralUInt32LE(0x02014b50); // Central directory file header signature | |
| writeCentralUInt16LE(20); // Version made by | |
| writeCentralUInt16LE(20); // Version needed | |
| writeCentralUInt16LE(0); // General purpose bit flag | |
| writeCentralUInt16LE(0); // Compression method | |
| writeCentralUInt16LE(dosTime); | |
| writeCentralUInt16LE(dosDate); | |
| writeCentralUInt32LE(0); // CRC-32 | |
| writeCentralUInt32LE(fileSize); | |
| writeCentralUInt32LE(fileSize); | |
| writeCentralUInt16LE(fileNameLength); | |
| writeCentralUInt16LE(0); // Extra field length | |
| writeCentralUInt16LE(0); // File comment length | |
| writeCentralUInt16LE(0); // Disk number start | |
| writeCentralUInt16LE(0); // Internal file attributes | |
| writeCentralUInt32LE(0); // External file attributes | |
| writeCentralUInt32LE(localHeaderOffset); // Correct relative offset | |
| centralHeader.set(fileNameBytes, offset); | |
| // --- End of Central Directory Record (EOCD) --- | |
| const eocd = new Uint8Array(22); | |
| offset = 0; | |
| const writeEOCDUInt32LE = (value) => { | |
| eocd[offset++] = value & 0xff; | |
| eocd[offset++] = (value >> 8) & 0xff; | |
| eocd[offset++] = (value >> 16) & 0xff; | |
| eocd[offset++] = (value >> 24) & 0xff; | |
| }; | |
| const writeEOCDUInt16LE = (value) => { | |
| eocd[offset++] = value & 0xff; | |
| eocd[offset++] = (value >> 8) & 0xff; | |
| }; | |
| writeEOCDUInt32LE(0x06054b50); // EOCD signature | |
| writeEOCDUInt16LE(0); // Disk number | |
| writeEOCDUInt16LE(0); // Disk where central directory starts | |
| writeEOCDUInt16LE(1); // Number of central directory records on this disk | |
| writeEOCDUInt16LE(1); // Total number of records | |
| writeEOCDUInt32LE(centralHeader.length); // Size of central directory | |
| writeEOCDUInt32LE(fileData.length); // Offset of start of central dir | |
| writeEOCDUInt16LE(0); // Comment length | |
| return new Blob([fileData, centralHeader, eocd], { type: "application/zip" }); | |
| }, | |
| addImageDownloader(container, getImageSource, options = {}) { | |
| const { | |
| buttonOptions = { title: "Download Image" }, | |
| filename = "processed-image.png", | |
| forceZip = false, | |
| showStatus = true, | |
| initialStatus = "Idle", | |
| onStart, | |
| onPrepare, | |
| onRender, | |
| onEncode, | |
| onFinish, | |
| onError, | |
| onFormatChange, | |
| onQualityChange, | |
| formatOptions = { enabled: true, defaultFormat: "png" }, | |
| zipOptions = { enabled: true, defaultZip: false }, | |
| qualityOptions = { enabled: true, defaultQuality: 100 }, | |
| } = options; | |
| const downloadButton = container.addButton(buttonOptions); | |
| const userOptions = { | |
| format: formatOptions.defaultFormat, | |
| zip: zipOptions.defaultZip, | |
| quality: qualityOptions.defaultQuality, | |
| }; | |
| const formatBinding = formatOptions.enabled ? | |
| container.addBinding(userOptions, "format", { | |
| label: "Format", | |
| options: { | |
| PNG: "png", | |
| JPEG: "jpeg", | |
| WebP: "webp", | |
| }, | |
| }) : | |
| null; | |
| const zipBinding = zipOptions.enabled ? | |
| container.addBinding(userOptions, "zip", { label: "ZIP" }) : | |
| null; | |
| const qualityBinding = qualityOptions.enabled ? | |
| container.addBinding(userOptions, "quality", { | |
| label: "Quality", | |
| min: 1, | |
| max: 100, | |
| step: 1, | |
| }) : | |
| null; | |
| // --- Quality visibility toggle --- | |
| if (formatBinding) { | |
| formatBinding.on("change", (ev) => { | |
| const fmt = ev.value; | |
| if (typeof onFormatChange === "function") onFormatChange(fmt); | |
| if (qualityBinding) { | |
| qualityBinding.element.style.display = fmt === "jpeg" || fmt === "webp" ? "" : "none"; | |
| } | |
| }); | |
| } | |
| // --- Quality change handler --- | |
| if (qualityBinding) { | |
| qualityBinding.on("change", (ev) => { | |
| if (typeof onQualityChange === "function") onQualityChange(ev.value); | |
| }); | |
| // Set initial visibility based on default format | |
| const initialFormat = formatBinding ? userOptions.format : formatOptions.defaultFormat; | |
| qualityBinding.element.style.display = initialFormat === "jpeg" || initialFormat === "webp" ? "" : "none"; | |
| } | |
| // Status field | |
| let statusField = null; | |
| let statusTimeout = null; | |
| if (showStatus) { | |
| statusField = TweakpaneUtils.addPersistentMessage(container, undefined, { | |
| initial: initialStatus, | |
| index: container.children.length, | |
| }); | |
| } | |
| const setStatus = (msg, autoReset = false) => { | |
| if (statusField) statusField.set(msg); | |
| if (statusTimeout) clearTimeout(statusTimeout); | |
| if (autoReset) { | |
| statusTimeout = setTimeout(() => { | |
| try { | |
| statusField?.set(initialStatus); | |
| } catch (e) { | |
| console.warn("Failed to reset status:", e); | |
| } | |
| }, 3000); | |
| } | |
| }; | |
| // --- Download Logic --- | |
| downloadButton.on("click", async () => { | |
| if (typeof onStart === "function") onStart(); | |
| try { | |
| setStatus("Preparing image..."); | |
| if (typeof onPrepare === "function") onPrepare(); | |
| const result = await getImageSource(); | |
| if (!result || (!result.imageData && !result.blob)) { | |
| const error = "No image data or blob returned."; | |
| console.warn(error); | |
| setStatus("Failed to generate image"); | |
| if (typeof onError === "function") onError(error); | |
| return; | |
| } | |
| const format = userOptions.format || result.format || "png"; | |
| const mimeType = `image/${format}`; | |
| const useQuality = format === "jpeg" || format === "webp"; | |
| const normalizedQuality = useQuality ? userOptions.quality / 100 : undefined; | |
| const timestamp = new Date() | |
| .toISOString() | |
| .replace(/[:.]/g, "-") | |
| .replace("T", "_") | |
| .slice(0, 19); | |
| const base = typeof filename === "function" ? filename() : filename; | |
| const ext = format; | |
| const finalName = base.replace(/(\.\w+)?$/, `-${timestamp}.${ext}`); | |
| let downloadBlob = null; | |
| if (result.blob) { | |
| setStatus("Using encoded blob..."); | |
| if (typeof onRender === "function") onRender(); | |
| downloadBlob = result.blob; | |
| } else { | |
| const { imageData } = result; | |
| setStatus("Rendering image..."); | |
| if (typeof onRender === "function") onRender(); | |
| const canvas = document.createElement("canvas"); | |
| canvas.width = imageData.width; | |
| canvas.height = imageData.height; | |
| const ctx = canvas.getContext("2d"); | |
| await new Promise((resolve) => { | |
| requestAnimationFrame(() => { | |
| ctx.putImageData(imageData, 0, 0); | |
| resolve(); | |
| }); | |
| }); | |
| setStatus("Encoding..."); | |
| if (typeof onEncode === "function") onEncode(); | |
| downloadBlob = await new Promise((resolve) => { | |
| canvas.toBlob( | |
| (blob) => resolve(blob), | |
| mimeType, | |
| normalizedQuality | |
| ); | |
| }); | |
| if (!downloadBlob) { | |
| const error = "Failed to encode image."; | |
| setStatus(error); | |
| if (typeof onError === "function") onError(error); | |
| return; | |
| } | |
| canvas.width = canvas.height = 0; | |
| } | |
| let downloadName = finalName; | |
| const shouldZip = zipOptions.enabled ? userOptions.zip : forceZip; | |
| if (shouldZip) { | |
| setStatus("Zipping..."); | |
| downloadBlob = await TweakpaneUtils.createZipWithFile(downloadBlob, finalName); | |
| downloadName = finalName.replace(/\.\w+$/, ".zip"); | |
| } | |
| const url = URL.createObjectURL(downloadBlob); | |
| const link = document.createElement("a"); | |
| link.style.position = "absolute"; | |
| link.href = url; | |
| link.rel = "noopener"; | |
| link.download = downloadName; | |
| document.body.appendChild(link); | |
| const clickEvent = new MouseEvent("click", { | |
| view: window, | |
| bubbles: true, | |
| cancelable: true, | |
| }); | |
| link.dispatchEvent(clickEvent); | |
| setTimeout(() => { | |
| document.body.removeChild(link); | |
| URL.revokeObjectURL(url); | |
| setStatus("Download complete ✔", true); | |
| if (typeof onFinish === "function") onFinish(); | |
| }, 250); | |
| } catch (e) { | |
| console.error(e); | |
| setStatus("Error during download"); | |
| if (typeof onError === "function") onError(e); | |
| } | |
| }); | |
| return { | |
| button: downloadButton, | |
| setStatus, | |
| clearStatus: () => statusField?.clear?.(), | |
| statusField, | |
| formatBinding, | |
| zipBinding, | |
| qualityBinding, | |
| }; | |
| }, | |
| addDemoImages(pane, onImageLoad, options = {}) { | |
| const { | |
| baseURL = "https://www.lessrain.com/dev/images/lr-demo-img-", | |
| totalImages = 370, | |
| thumbnailClass = "tp-demo-thumbnails", | |
| folderOptions = { title: "Demo Images" }, | |
| thumbnailExtensions = ["png"], | |
| imageExtensions = ["jpg", "webp", "png"], | |
| onThumbnailClick = null, | |
| ordered = false, | |
| startIndex = 1, | |
| } = options; | |
| const demoFolder = pane.addFolder(folderOptions); | |
| const thumbnailContainer = document.createElement("div"); | |
| thumbnailContainer.classList.add(thumbnailClass); | |
| let demoImageIds = []; | |
| let orderedOffset = ordered ? (startIndex - 1) % totalImages : 0; | |
| let hasInitialized = false; | |
| function shuffleArray(array) { | |
| for (let i = array.length - 1; i > 0; i--) { | |
| const j = Math.floor(Math.random() * (i + 1)); | |
| [array[i], array[j]] = [array[j], array[i]]; | |
| } | |
| } | |
| async function tryImageExtensions(baseUrl, extensions) { | |
| for (const ext of extensions) { | |
| const url = `${baseUrl}.${ext}`; | |
| const img = new Image(); | |
| img.src = url; | |
| img.crossOrigin = "Anonymous"; | |
| const isValid = await new Promise((resolve) => { | |
| img.onload = () => resolve(true); | |
| img.onerror = () => resolve(false); | |
| }); | |
| if (isValid) return url; | |
| } | |
| return null; | |
| } | |
| function generateThumbnails() { | |
| while (thumbnailContainer.firstChild) { | |
| const child = thumbnailContainer.firstChild; | |
| const img = child.querySelector("img"); | |
| if (img) img.src = ""; | |
| thumbnailContainer.removeChild(child); | |
| } | |
| const allImageIds = Array.from({ length: totalImages }, (_, i) => i + 1); | |
| if (ordered) { | |
| const slice = []; | |
| for (let i = 0; i < 20; i++) { | |
| const id = allImageIds[(orderedOffset + i) % allImageIds.length]; | |
| slice.push(id); | |
| } | |
| orderedOffset = (orderedOffset + 20) % allImageIds.length; | |
| demoImageIds = slice; | |
| } else { | |
| shuffleArray(allImageIds); | |
| if (!hasInitialized) { | |
| const startId = ((startIndex - 1) % totalImages) + 1; | |
| const rest = allImageIds.filter((id) => id !== startId); | |
| demoImageIds = [startId, ...rest.slice(0, 19)]; | |
| hasInitialized = true; | |
| } else { | |
| demoImageIds = allImageIds.slice(0, 20); | |
| } | |
| } | |
| for (let i = 0; i < demoImageIds.length; i++) { | |
| const thumbnailWrapper = document.createElement("div"); | |
| thumbnailWrapper.classList.add("tp-demo-thumbnail"); | |
| const thumbnailImg = document.createElement("img"); | |
| thumbnailWrapper.appendChild(thumbnailImg); | |
| thumbnailContainer.appendChild(thumbnailWrapper); | |
| } | |
| Array.from(thumbnailContainer.children).forEach(async (thumbnailWrapper, index) => { | |
| const id = demoImageIds[index]; | |
| const name = `Image ${id}`; | |
| const baseUrl = `${baseURL}${id}`; | |
| const thumbnailUrl = await tryImageExtensions(`${baseUrl}-thumb`, thumbnailExtensions); | |
| if (!thumbnailUrl) return; | |
| const thumbnailImg = thumbnailWrapper.querySelector("img"); | |
| thumbnailImg.src = thumbnailUrl; | |
| thumbnailImg.alt = name; | |
| thumbnailWrapper.addEventListener("click", async () => { | |
| if (typeof onThumbnailClick === "function") { | |
| onThumbnailClick(name, baseUrl); | |
| } | |
| const imageUrl = await tryImageExtensions(baseUrl, imageExtensions); | |
| if (imageUrl) { | |
| onImageLoad(imageUrl); | |
| } else { | |
| console.error(`Failed to load image: ${baseUrl}`); | |
| } | |
| }); | |
| }); | |
| } | |
| function getImageList() { | |
| return demoImageIds.map((id) => `${baseURL}${id}`); | |
| } | |
| function loadImageIndex(index) { | |
| if (demoImageIds.length === 0) { | |
| console.warn("No images loaded yet."); | |
| return; | |
| } | |
| const safeIndex = ((index % demoImageIds.length) + demoImageIds.length) % demoImageIds.length; | |
| const baseUrl = `${baseURL}${demoImageIds[safeIndex]}`; | |
| tryImageExtensions(baseUrl, imageExtensions).then((imageUrl) => { | |
| if (imageUrl) { | |
| onImageLoad(imageUrl); | |
| } else { | |
| console.error(`Failed to load image: ${baseUrl}`); | |
| } | |
| }); | |
| } | |
| demoFolder.addButton({ title: "Refresh Thumbnails" }).on("click", generateThumbnails); | |
| generateThumbnails(); | |
| TweakpaneUtils.appendToFolderContent(demoFolder, thumbnailContainer); | |
| return { | |
| folder: demoFolder, | |
| getImageList, | |
| loadImageIndex, | |
| }; | |
| }, | |
| setEnabled(pane, isEnabled) { | |
| pane.children.forEach((control) => { | |
| if (control.disabled !== undefined) { | |
| control.disabled = !isEnabled; | |
| } | |
| }); | |
| pane.element.querySelectorAll(".tp-fldv, .tp-fldv_c").forEach((folder) => { | |
| folder.style.pointerEvents = isEnabled ? "auto" : "none"; | |
| folder.style.opacity = isEnabled ? "1" : "0.75"; | |
| }); | |
| pane.element.querySelectorAll("button").forEach((button) => { | |
| button.disabled = !isEnabled; | |
| }); | |
| // Disable all inputs | |
| pane.element.querySelectorAll("input, select").forEach((input) => { | |
| input.disabled = !isEnabled; | |
| }); | |
| }, | |
| addPersistentMessage(container, label = undefined, options = {}) { | |
| const { | |
| initial = "", | |
| index = undefined, | |
| multiline = true, | |
| rows = 1, | |
| } = options; | |
| const proxy = { status: initial }; | |
| const bindingOptions = { | |
| readonly: true, | |
| multiline, | |
| rows, | |
| index, | |
| label: label !== undefined ? label : "", | |
| }; | |
| const statusBlade = container.addBinding(proxy, "status", bindingOptions); | |
| requestAnimationFrame(() => { | |
| const textareaEl = statusBlade.element.querySelector("textarea"); | |
| if (textareaEl) { | |
| textareaEl.style.whiteSpace = "pre-wrap"; | |
| textareaEl.style.overflow = "hidden"; | |
| textareaEl.style.wordBreak = "break-word"; | |
| textareaEl.style.resize = "none"; | |
| // Fix for single-line height | |
| textareaEl.style.paddingTop = "4px"; | |
| textareaEl.style.paddingBottom = "0px"; | |
| textareaEl.style.lineHeight = "1.2"; | |
| textareaEl.rows = 1; | |
| const resize = () => { | |
| textareaEl.style.height = "0px"; | |
| textareaEl.style.minHeight = "20px"; | |
| textareaEl.style.maxHeight = "none"; | |
| textareaEl.style.height = `${textareaEl.scrollHeight}px`; | |
| }; | |
| resize(); | |
| statusBlade._resizeTextarea = resize; | |
| } | |
| if (label === undefined) { | |
| const labelContainer = statusBlade.element.querySelector(".tp-lblv_l"); | |
| if (labelContainer) { | |
| labelContainer.style.display = "none"; | |
| if (labelContainer.nextElementSibling) { | |
| labelContainer.nextElementSibling.style.width = "100%"; | |
| } | |
| } | |
| } | |
| }); | |
| return { | |
| set(text) { | |
| proxy.status = text; | |
| statusBlade.refresh(); | |
| requestAnimationFrame(() => { | |
| statusBlade._resizeTextarea?.(); | |
| }); | |
| }, | |
| clear() { | |
| proxy.status = ""; | |
| statusBlade.refresh(); | |
| requestAnimationFrame(() => { | |
| statusBlade._resizeTextarea?.(); | |
| }); | |
| }, | |
| blade: statusBlade, | |
| }; | |
| }, | |
| }; | |
| document.addEventListener("DOMContentLoaded", () => { | |
| const pane = new Pane(); | |
| const canvas = document.getElementById("output"); | |
| const ctx = canvas.getContext("2d"); | |
| const bufferCanvas = document.createElement("canvas"); | |
| const bufferCtx = bufferCanvas.getContext("2d"); | |
| let originalImage = null; | |
| let originalImageData = null; | |
| let effectManager = null; | |
| let originalImageMeta = { | |
| scaleFactor: 1, | |
| originalWidth: 0, | |
| originalHeight: 0, | |
| }; | |
| let canvasResolution = { width: 0, height: 0 }; | |
| let canvasOffset = CSSUtils.getCssVariableValue(canvas, "--canvas-offset", true); | |
| let maxCanvasWidth = CSSUtils.getCssVariableValue(canvas, "--max-canvas-width", true); | |
| let maxCanvasHeight = CSSUtils.getCssVariableValue(canvas, "--max-canvas-height", true); | |
| function clamp(v, min = 0, max = 255) { | |
| return v < min ? min : v > max ? max : v; | |
| } | |
| function mulberry32(a) { | |
| return function() { | |
| a |= 0; | |
| a = a + 0x6D2B79F5 | 0; | |
| let t = Math.imul(a ^ a >>> 15, 1 | a); | |
| t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; | |
| return ((t ^ t >>> 14) >>> 0) / 4294967296; | |
| }; | |
| } | |
| async function safeUpdate(effectId) { | |
| if (!effectManager) return; | |
| effectManager.markDirty(effectId); | |
| const output = await effectManager.process(); | |
| ctx.putImageData(output, 0, 0); | |
| } | |
| async function processImage() { | |
| if (!effectManager || !originalImageData) return; | |
| effectManager.updateOriginal(originalImageData); | |
| const output = await effectManager.process(); | |
| ctx.putImageData(output, 0, 0); | |
| } | |
| function createEffectManager(originalImageData) { | |
| const effects = []; | |
| let original = originalImageData; | |
| const manager = { | |
| updateOriginal(newData) { | |
| original = newData; | |
| for (const e of effects) { | |
| e.cache = null; | |
| e.dirty = true; | |
| e.outputBuffer = null; | |
| } | |
| }, | |
| addEffect(id, fn, { enabled = true } = {}) { | |
| effects.push({ | |
| id, | |
| fn, | |
| enabled, | |
| dirty: true, | |
| cache: null, | |
| outputBuffer: null, | |
| }); | |
| }, | |
| setEnabled(id, isEnabled) { | |
| const effect = effects.find(e => e.id === id); | |
| if (effect) { | |
| effect.enabled = isEnabled; | |
| effect.dirty = true; | |
| } | |
| }, | |
| markDirty(id) { | |
| const index = effects.findIndex(e => e.id === id); | |
| if (index === -1) return; | |
| for (let i = index; i < effects.length; i++) { | |
| effects[i].dirty = true; | |
| effects[i].cache = null; | |
| } | |
| }, | |
| async process() { | |
| let input = original; | |
| for (const e of effects) { | |
| if (!e.enabled) continue; | |
| const { width, height } = input; | |
| if (!e.outputBuffer || e.outputBuffer.width !== width || e.outputBuffer.height !== height) { | |
| e.outputBuffer = new ImageData(width, height); | |
| } | |
| if (e.dirty || !e.cache) { | |
| const result = e.fn(input, e.outputBuffer); | |
| e.cache = result instanceof Promise ? await result : result; | |
| e.dirty = false; | |
| } | |
| input = e.cache; | |
| } | |
| return input; | |
| }, | |
| dispose() { | |
| for (const e of effects) { | |
| e.cache = null; | |
| e.outputBuffer = null; | |
| e.fn = null; | |
| } | |
| effects.length = 0; | |
| original = null; | |
| } | |
| }; | |
| return manager; | |
| } | |
| async function getDownloadImageData() { | |
| const isScaled = originalImageMeta.scaleFactor < 1; | |
| if (!isScaled) { | |
| return await effectManager.process(); | |
| } | |
| const tempCanvas = document.createElement("canvas"); | |
| tempCanvas.width = originalImageMeta.originalWidth; | |
| tempCanvas.height = originalImageMeta.originalHeight; | |
| const tempCtx = tempCanvas.getContext("2d"); | |
| tempCtx.drawImage(originalImage, 0, 0, tempCanvas.width, tempCanvas.height); | |
| let highResImageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); | |
| const scale = originalImageMeta.originalWidth / canvas.width; | |
| if (paletteReductionParams.enablePaletteReduction) { | |
| const reduced = new ImageData(highResImageData.width, highResImageData.height); | |
| applyPaletteReduction(highResImageData, reduced); | |
| highResImageData = reduced; | |
| } | |
| const highResManager = createEffectManager(highResImageData); | |
| highResManager.addEffect("colorShift", makeScaledColorShiftEffect(scale), { | |
| enabled: colorShiftParams.enableColorShift, | |
| }); | |
| highResManager.addEffect("waveDeform", makeScaledWaveDeformEffect(scale), { | |
| enabled: waveDeformParams.enableWaveDeform, | |
| }); | |
| highResManager.addEffect("displacement", makeScaledDisplacementEffect(scale), { | |
| enabled: displacementParams.enableDisplacement | |
| }); | |
| highResManager.addEffect("pixelSort", makeScaledPixelSortEffect(scale), { | |
| enabled: pixelSortParams.enablePixelSort, | |
| }); | |
| highResManager.addEffect("dataCorruption", makeScaledDataCorruptionEffect(scale), { | |
| enabled: dataCorruptionParams.enableDataCorruption | |
| }); | |
| highResManager.updateOriginal(highResImageData); | |
| const output = await highResManager.process(); | |
| highResManager.dispose(); | |
| tempCanvas.width = tempCanvas.height = 0; | |
| return output; | |
| } | |
| const colorShiftParams = { | |
| enableColorShift: true, | |
| useUniformShift: true, | |
| shiftAmount: { x: 20, y: 0 }, | |
| redShift: { x: 20, y: 0 }, | |
| greenShift: { x: 0, y: 0 }, | |
| blueShift: { x: -20, y: 0 }, | |
| intensity: 1.0, | |
| }; | |
| const waveDeformParams = { | |
| enableWaveDeform: false, | |
| direction: "horizontal", | |
| amplitude: 10, | |
| frequency: 0.05, | |
| phase: 0, | |
| useNoise: false, | |
| seed: 0 | |
| }; | |
| const displacementParams = { | |
| enableDisplacement: true, | |
| mode: 'horizontal', | |
| displacementIntensity: 8, | |
| displacementSize: 18, | |
| displacementFrequency: 0.5, | |
| seed: 0, | |
| }; | |
| const predefinedPalettes = { | |
| // Original palettes | |
| gameboy: [ | |
| [15, 56, 15], | |
| [48, 98, 48], | |
| [139, 172, 15], | |
| [155, 188, 15] | |
| ], | |
| firewatch: [ | |
| [255, 94, 77], | |
| [255, 160, 0], | |
| [72, 52, 212], | |
| [29, 29, 29] | |
| ], | |
| desert: [ | |
| [239, 214, 167], | |
| [201, 133, 61], | |
| [129, 80, 47], | |
| [60, 42, 33] | |
| ], | |
| lavender: [ | |
| [32, 32, 64], | |
| [96, 64, 128], | |
| [160, 128, 192], | |
| [240, 240, 255] | |
| ], | |
| strangerThings: [ | |
| [12, 12, 20], | |
| [220, 30, 30], | |
| [240, 240, 240], | |
| [30, 30, 60] | |
| ], | |
| dawnbringer: [ | |
| [20, 12, 28], | |
| [68, 36, 52], | |
| [48, 52, 109], | |
| [208, 70, 72], | |
| [210, 125, 44], | |
| [109, 194, 202], | |
| [218, 212, 94], | |
| [222, 238, 214] | |
| ], | |
| blackwhite: [ | |
| [0, 0, 0], | |
| [255, 255, 255] | |
| ], | |
| grayscale4: [ | |
| [0, 0, 0], | |
| [85, 85, 85], | |
| [170, 170, 170], | |
| [255, 255, 255] | |
| ], | |
| // Movie-inspired palettes | |
| bladeRunner: [ | |
| [10, 10, 30], | |
| [200, 30, 60], | |
| [30, 150, 200], | |
| [250, 180, 80] | |
| ], | |
| madMax: [ | |
| [255, 213, 79], | |
| [244, 67, 54], | |
| [33, 33, 33], | |
| [158, 158, 158] | |
| ], | |
| matrix: [ | |
| [0, 0, 0], | |
| [0, 255, 70], | |
| [20, 20, 20], | |
| [100, 255, 180] | |
| ], | |
| tronLegacy: [ | |
| [0, 0, 0], | |
| [0, 240, 255], | |
| [255, 255, 255], | |
| [0, 60, 160] | |
| ], | |
| drive: [ | |
| [255, 0, 128], | |
| [255, 255, 255], | |
| [10, 10, 10], | |
| [80, 0, 120] | |
| ], | |
| akira: [ | |
| [255, 0, 0], | |
| [30, 30, 30], | |
| [255, 230, 200], | |
| [80, 80, 80] | |
| ], | |
| // Retro/Vapor Aesthetic palettes | |
| vaporwave: [ | |
| [255, 105, 180], | |
| [0, 255, 255], | |
| [255, 255, 255], | |
| [20, 20, 20] | |
| ], | |
| miamiVice: [ | |
| [255, 85, 170], | |
| [0, 204, 204], | |
| [255, 255, 255], | |
| [0, 0, 0] | |
| ], | |
| lofi: [ | |
| [144, 129, 112], | |
| [192, 159, 142], | |
| [236, 208, 185], | |
| [78, 61, 53] | |
| ], | |
| nes: [ | |
| [124, 124, 124], | |
| [0, 0, 252], | |
| [252, 0, 0], | |
| [0, 0, 0] | |
| ], | |
| // Abstract/Glitch/Experimental | |
| glitchCore: [ | |
| [0, 255, 255], | |
| [255, 0, 255], | |
| [255, 255, 0], | |
| [0, 0, 0] | |
| ], | |
| acid: [ | |
| [255, 0, 255], | |
| [0, 255, 0], | |
| [0, 0, 255], | |
| [255, 255, 0] | |
| ] | |
| }; | |
| const paletteReductionParams = { | |
| enablePaletteReduction: false, | |
| paletteName: "desert", | |
| distanceMode: "accurate", | |
| useDithering: true | |
| }; | |
| const pixelSortParams = { | |
| enablePixelSort: false, | |
| direction: 'horizontal', | |
| blockSize: 5, | |
| frequency: 0.5, | |
| sortType: 'shuffle', | |
| seed: 0, | |
| }; | |
| const dataCorruptionParams = { | |
| enableDataCorruption: false, | |
| blockSize: 32, | |
| corruptionAmount: 0.01, | |
| corruptionMode: "random", | |
| seed: 0 | |
| }; | |
| function resizeCanvas() { | |
| const { width, height } = canvasResolution; | |
| if (!width || !height) return; | |
| const viewportWidth = window.innerWidth; | |
| const viewportHeight = window.innerHeight; | |
| const maxCanvasSize = Math.min(viewportWidth, viewportHeight) - canvasOffset; | |
| const scale = Math.min(maxCanvasSize / width, maxCanvasSize / height, 1); | |
| canvas.style.width = `${Math.round(width * scale)}px`; | |
| canvas.style.height = `${Math.round(height * scale)}px`; | |
| } | |
| function preprocessBaseImage(imageData) { | |
| const tempCtx = document.createElement("canvas").getContext("2d"); | |
| const processed = tempCtx.createImageData(imageData.width, imageData.height); | |
| let modified = false; | |
| if (paletteReductionParams.enablePaletteReduction) { | |
| applyPaletteReduction(imageData, processed); | |
| modified = true; | |
| } | |
| return modified ? processed : imageData; | |
| } | |
| function drawImageOnCanvas(imageSrc) { | |
| //TweakpaneUtils.setEnabled(pane, false); | |
| if (originalImage) { | |
| originalImage.onload = null; | |
| originalImage.onerror = null; | |
| originalImage.src = ""; | |
| originalImage = null; | |
| } | |
| if (effectManager && typeof effectManager.dispose === "function") { | |
| effectManager.dispose(); | |
| effectManager = null; | |
| } | |
| originalImageData = null; | |
| originalImage = new Image(); | |
| originalImage.crossOrigin = "Anonymous"; | |
| originalImage.src = imageSrc; | |
| originalImage.onload = function() { | |
| const origW = originalImage.naturalWidth; | |
| const origH = originalImage.naturalHeight; | |
| const scale = Math.min(maxCanvasWidth / origW, maxCanvasHeight / origH, 1); | |
| const scaledW = Math.floor(origW * scale); | |
| const scaledH = Math.floor(origH * scale); | |
| canvas.width = scaledW; | |
| canvas.height = scaledH; | |
| canvasResolution.width = scaledW; | |
| canvasResolution.height = scaledH; | |
| resizeCanvas(); | |
| ctx.imageSmoothingEnabled = true; | |
| ctx.drawImage(originalImage, 0, 0, scaledW, scaledH); | |
| let baseImageData = ctx.getImageData(0, 0, scaledW, scaledH); | |
| if (paletteReductionParams.enablePaletteReduction) { | |
| const reduced = ctx.createImageData(baseImageData.width, baseImageData.height); | |
| applyPaletteReduction(baseImageData, reduced); | |
| baseImageData = reduced; | |
| ctx.putImageData(baseImageData, 0, 0); | |
| } | |
| originalImageData = baseImageData; | |
| originalImageMeta.scaleFactor = scale; | |
| originalImageMeta.originalWidth = origW; | |
| originalImageMeta.originalHeight = origH; | |
| effectManager = createEffectManager(originalImageData); | |
| effectManager.addEffect("colorShift", applyColorShift, { | |
| enabled: colorShiftParams.enableColorShift, | |
| }); | |
| effectManager.addEffect("waveDeform", applyWaveDeform, { | |
| enabled: waveDeformParams.enableWaveDeform | |
| }); | |
| effectManager.addEffect("displacement", applyDisplacement, { | |
| enabled: displacementParams.enableDisplacement, | |
| }); | |
| effectManager.addEffect("pixelSort", applyPixelSort, { | |
| enabled: pixelSortParams.enablePixelSort, | |
| }); | |
| effectManager.addEffect("dataCorruption", applyDataCorruption, { | |
| enabled: dataCorruptionParams.enableDataCorruption, | |
| }); | |
| effectManager.updateOriginal(originalImageData); | |
| processImage(); | |
| //TweakpaneUtils.setEnabled(pane, true); | |
| if (scale < 1) { | |
| qualityMessage.set(`Image scaled to ${Math.round(scale * 100)}% for performance`); | |
| } else { | |
| qualityMessage.set("Original resolution preserved"); | |
| } | |
| }; | |
| originalImage.onerror = function() { | |
| console.error("Failed to load image"); | |
| TweakpaneUtils.setEnabled(pane, true); | |
| }; | |
| } | |
| const optimizedPalettes = new Map(); | |
| function precomputePalettes() { | |
| let name, colors, arr, i, color; | |
| for ([name, colors] of Object.entries(predefinedPalettes)) { | |
| arr = new Uint8Array(colors.length * 4); | |
| for (i = 0; i < colors.length; i++) { | |
| color = colors[i]; | |
| arr[i * 4] = color[0]; | |
| arr[i * 4 + 1] = color[1]; | |
| arr[i * 4 + 2] = color[2]; | |
| arr[i * 4 + 3] = 255; | |
| } | |
| optimizedPalettes.set(name, arr); | |
| } | |
| } | |
| precomputePalettes(); | |
| function findNearestColor(r, g, b, palette) { | |
| let minDist = Infinity; | |
| let bestPr = 0, | |
| bestPg = 0, | |
| bestPb = 0; | |
| let i, pr, pg, pb, dr, dg, db, dist; | |
| const len = palette.length; | |
| for (i = 0; i < len; i++) { | |
| [pr, pg, pb] = palette[i]; | |
| dr = r - pr; | |
| dg = g - pg; | |
| db = b - pb; | |
| dist = dr * dr + dg * dg + db * db; | |
| if (dist < minDist) { | |
| minDist = dist; | |
| bestPr = pr; | |
| bestPg = pg; | |
| bestPb = pb; | |
| if (dist === 0) break; | |
| } | |
| } | |
| return [bestPr, bestPg, bestPb]; | |
| } | |
| function getDistanceFunction(mode) { | |
| switch (mode) { | |
| case "fast": | |
| return function(r, g, b, pr, pg, pb) { | |
| return Math.abs(r - pr) + Math.abs(g - pg) + Math.abs(b - pb); | |
| }; | |
| case "perceptual": | |
| return function(r, g, b, pr, pg, pb) { | |
| let dr = r - pr; | |
| let dg = g - pg; | |
| let db = b - pb; | |
| return 0.3 * dr * dr + 0.59 * dg * dg + 0.11 * db * db; | |
| }; | |
| case "accurate": | |
| default: | |
| return function(r, g, b, pr, pg, pb) { | |
| let dr = r - pr; | |
| let dg = g - pg; | |
| let db = b - pb; | |
| return dr * dr + dg * dg + db * db; | |
| }; | |
| } | |
| } | |
| function distributeError(data, width, height, x, y, errR, errG, errB) { | |
| const diffusion = [ | |
| [1, 0, 7 / 16], | |
| [-1, 1, 3 / 16], | |
| [0, 1, 5 / 16], | |
| [1, 1, 1 / 16] | |
| ]; | |
| for (const [dx, dy, factor] of diffusion) { | |
| const nx = x + dx; | |
| const ny = y + dy; | |
| if (nx >= 0 && nx < width && ny >= 0 && ny < height) { | |
| const ni = (ny * width + nx) * 4; | |
| data[ni] = clamp(data[ni] + errR * factor); | |
| data[ni + 1] = clamp(data[ni + 1] + errG * factor); | |
| data[ni + 2] = clamp(data[ni + 2] + errB * factor); | |
| } | |
| } | |
| } | |
| function clamp(v, min = 0, max = 255) { | |
| return v < min ? min : v > max ? max : v; | |
| } | |
| function applyPaletteReduction(inputImageData, outputImageData) { | |
| const paletteName = paletteReductionParams.paletteName || "desert"; | |
| const distanceMode = paletteReductionParams.distanceMode || "accurate"; | |
| const useDithering = paletteReductionParams.useDithering; | |
| const palette = predefinedPalettes[paletteName] || predefinedPalettes.desert; | |
| const src = inputImageData.data; | |
| const dst = outputImageData.data; | |
| const width = inputImageData.width; | |
| const height = inputImageData.height; | |
| const len = src.length; | |
| const palLen = palette.length; | |
| const getDist = getDistanceFunction(distanceMode); | |
| const cache = new Map(); | |
| const palette32 = new Uint32Array(palLen); | |
| for (let j = 0; j < palLen; j++) { | |
| const [pr, pg, pb] = palette[j]; | |
| palette32[j] = (pr << 24) | (pg << 16) | (pb << 8); | |
| } | |
| // Create working buffer if dithering; else use source directly | |
| const temp = useDithering ? new Uint8ClampedArray(src) : src; | |
| let i = 0, | |
| j = 0; | |
| let r = 0, | |
| g = 0, | |
| b = 0, | |
| a = 0; | |
| let key = 0, | |
| match = 0, | |
| minDist = 0, | |
| dist = 0, | |
| nearest = 0; | |
| let pr = 0, | |
| pg = 0, | |
| pb = 0; | |
| let errR = 0, | |
| errG = 0, | |
| errB = 0; | |
| let x = 0, | |
| y = 0; | |
| for (i = 0; i < len; i += 4) { | |
| r = temp[i]; | |
| g = temp[i + 1]; | |
| b = temp[i + 2]; | |
| a = temp[i + 3]; | |
| key = (r << 16) | (g << 8) | b; | |
| match = cache.get(key); | |
| if (match === undefined) { | |
| minDist = Infinity; | |
| nearest = 0; | |
| for (j = 0; j < palLen; j++) { | |
| const p = palette32[j]; | |
| pr = (p >> 24) & 0xFF; | |
| pg = (p >> 16) & 0xFF; | |
| pb = (p >> 8) & 0xFF; | |
| dist = getDist(r, g, b, pr, pg, pb); | |
| if (dist < minDist) { | |
| minDist = dist; | |
| nearest = p; | |
| if (dist === 0) break; | |
| } | |
| } | |
| match = nearest; | |
| cache.set(key, match); | |
| } | |
| const nr = (match >> 24) & 0xFF; | |
| const ng = (match >> 16) & 0xFF; | |
| const nb = (match >> 8) & 0xFF; | |
| dst[i] = nr; | |
| dst[i + 1] = ng; | |
| dst[i + 2] = nb; | |
| dst[i + 3] = a; | |
| if (useDithering) { | |
| errR = r - nr; | |
| errG = g - ng; | |
| errB = b - nb; | |
| x = (i >> 2) % width; | |
| y = (i >> 2) / width | 0; | |
| distributeError(temp, width, height, x, y, errR, errG, errB); | |
| temp[i] = nr; | |
| temp[i + 1] = ng; | |
| temp[i + 2] = nb; | |
| } | |
| } | |
| return outputImageData; | |
| } | |
| function makeScaledPaletteReductionEffect() { | |
| return function(inputImageData, outputBuffer) { | |
| return applyPaletteReduction(inputImageData, outputBuffer); | |
| }; | |
| } | |
| function applyColorShift(inputImageData, output) { | |
| if (!inputImageData || !colorShiftParams.enableColorShift) { | |
| return inputImageData; | |
| } | |
| const width = inputImageData.width; | |
| const height = inputImageData.height; | |
| const src = inputImageData.data; | |
| const dst = output.data; | |
| const blend = colorShiftParams.intensity; | |
| const shiftR = colorShiftParams.useUniformShift ? colorShiftParams.shiftAmount : colorShiftParams.redShift; | |
| const shiftG = colorShiftParams.useUniformShift ? { x: 0, y: 0 } : colorShiftParams.greenShift; | |
| const shiftB = colorShiftParams.useUniformShift ? { x: -colorShiftParams.shiftAmount.x, y: -colorShiftParams.shiftAmount.y } : | |
| colorShiftParams.blueShift; | |
| const roundShiftR = { x: Math.round(shiftR.x), y: Math.round(shiftR.y) }; | |
| const roundShiftG = { x: Math.round(shiftG.x), y: Math.round(shiftG.y) }; | |
| const roundShiftB = { x: Math.round(shiftB.x), y: Math.round(shiftB.y) }; | |
| const maxX = width - 1; | |
| const maxY = height - 1; | |
| let i, xR, yR, xG, yG, xB, yB, r, g, b, rOrig, gOrig, bOrig, a, blendR, blendG, blendB, rValid, gValid, bValid; | |
| for (let y = 0; y < height; y++) { | |
| for (let x = 0; x < width; x++) { | |
| i = (y * width + x) << 2; | |
| xR = x + roundShiftR.x; | |
| yR = y + roundShiftR.y; | |
| xG = x + roundShiftG.x; | |
| yG = y + roundShiftG.y; | |
| xB = x + roundShiftB.x; | |
| yB = y + roundShiftB.y; | |
| rValid = xR >= 0 && xR <= maxX && yR >= 0 && yR <= maxY; | |
| gValid = xG >= 0 && xG <= maxX && yG >= 0 && yG <= maxY; | |
| bValid = xB >= 0 && xB <= maxX && yB >= 0 && yB <= maxY; | |
| r = rValid ? src[((yR * width + xR) << 2)] : 0; | |
| g = gValid ? src[((yG * width + xG) << 2) + 1] : 0; | |
| b = bValid ? src[((yB * width + xB) << 2) + 2] : 0; | |
| rOrig = src[i]; | |
| gOrig = src[i + 1]; | |
| bOrig = src[i + 2]; | |
| a = src[i + 3]; | |
| blendR = (blend * r + (1 - blend) * rOrig + 0.5) | 0; | |
| blendG = (blend * g + (1 - blend) * gOrig + 0.5) | 0; | |
| blendB = (blend * b + (1 - blend) * bOrig + 0.5) | 0; | |
| dst[i] = blendR; | |
| dst[i + 1] = blendG; | |
| dst[i + 2] = blendB; | |
| dst[i + 3] = a; | |
| } | |
| } | |
| return output; | |
| } | |
| function makeScaledColorShiftEffect(scale = 1) { | |
| return function(inputImageData, output) { | |
| // Create a deep copy of scaled parameters | |
| const getScaled = (v) => ({ x: v.x * scale, y: v.y * scale }); | |
| const scaledParams = { | |
| ...colorShiftParams, | |
| shiftAmount: getScaled(colorShiftParams.shiftAmount), | |
| redShift: getScaled(colorShiftParams.redShift), | |
| greenShift: getScaled(colorShiftParams.greenShift), | |
| blueShift: getScaled(colorShiftParams.blueShift) | |
| }; | |
| const original = { ...colorShiftParams }; | |
| Object.assign(colorShiftParams, scaledParams); | |
| const result = applyColorShift(inputImageData, output); | |
| Object.assign(colorShiftParams, original); | |
| return result; | |
| }; | |
| } | |
| function applyWaveDeform(inputImageData, outputImageData) { | |
| if (!waveDeformParams.enableWaveDeform) { | |
| return inputImageData; | |
| } | |
| const { width, height, data: src } = inputImageData; | |
| const dst = outputImageData.data; | |
| const { | |
| direction, | |
| amplitude, | |
| frequency, | |
| phase, | |
| useNoise, | |
| seed = 0 | |
| } = waveDeformParams; | |
| const isHorizontal = direction === "horizontal"; | |
| const widthMinus1 = width - 1; | |
| const heightMinus1 = height - 1; | |
| const rng = mulberry32(seed); | |
| const waveOffset = new Int32Array(isHorizontal ? height : width); | |
| if (useNoise) { | |
| for (let i = 0, len = waveOffset.length; i < len; i++) { | |
| waveOffset[i] = Math.round((rng() * 2 - 1) * amplitude); | |
| } | |
| } else { | |
| for (let i = 0, len = waveOffset.length; i < len; i++) { | |
| waveOffset[i] = Math.round(Math.sin(frequency * i + phase) * amplitude); | |
| } | |
| } | |
| let offset, yWidth, clampedYWidth, srcX, srcY, srcIdx, dstIdx; | |
| if (isHorizontal) { | |
| for (let y = 0; y < height; y++) { | |
| offset = waveOffset[y]; | |
| yWidth = y * width; | |
| clampedYWidth = y * width; | |
| for (let x = 0; x < width; x++) { | |
| srcX = Math.max(0, Math.min(widthMinus1, x + offset)); | |
| srcIdx = (yWidth + srcX) << 2; | |
| dstIdx = (clampedYWidth + x) << 2; | |
| dst[dstIdx] = src[srcIdx]; | |
| dst[dstIdx + 1] = src[srcIdx + 1]; | |
| dst[dstIdx + 2] = src[srcIdx + 2]; | |
| dst[dstIdx + 3] = src[srcIdx + 3]; | |
| } | |
| } | |
| } else { | |
| for (let x = 0; x < width; x++) { | |
| offset = waveOffset[x]; | |
| for (let y = 0; y < height; y++) { | |
| srcY = Math.max(0, Math.min(heightMinus1, y + offset)); | |
| srcIdx = (srcY * width + x) << 2; | |
| dstIdx = (y * width + x) << 2; | |
| dst[dstIdx] = src[srcIdx]; | |
| dst[dstIdx + 1] = src[srcIdx + 1]; | |
| dst[dstIdx + 2] = src[srcIdx + 2]; | |
| dst[dstIdx + 3] = src[srcIdx + 3]; | |
| } | |
| } | |
| } | |
| return outputImageData; | |
| } | |
| function makeScaledWaveDeformEffect(scale = 1) { | |
| return function(inputImageData, output) { | |
| const scaledParams = { | |
| ...waveDeformParams, | |
| amplitude: Math.round(waveDeformParams.amplitude * scale), | |
| frequency: waveDeformParams.frequency / scale, //inverse-scale to match cycles | |
| }; | |
| const original = { ...waveDeformParams }; | |
| Object.assign(waveDeformParams, scaledParams); | |
| const result = applyWaveDeform(inputImageData, output); | |
| Object.assign(waveDeformParams, original); | |
| return result; | |
| }; | |
| } | |
| function applyDisplacement(inputImageData, output) { | |
| if (!inputImageData || !displacementParams.enableDisplacement) { | |
| return inputImageData; | |
| } | |
| const width = inputImageData.width; | |
| const height = inputImageData.height; | |
| const src = inputImageData.data; | |
| const dst = output.data; | |
| const { | |
| mode, | |
| displacementIntensity, | |
| displacementSize, | |
| displacementFrequency, | |
| seed = 0, | |
| } = displacementParams; | |
| const rng = mulberry32(seed); | |
| const rowStride = width << 2; | |
| const height4 = height << 2; | |
| const width4 = width << 2; | |
| const rowBuffer = new Uint8ClampedArray(rowStride); | |
| const tempColBuffer = new Uint8ClampedArray(height4); | |
| const colBuffer = new Uint8ClampedArray(height4); | |
| let y, x, dy, dx, i, j, start, offset, srcIdx, dstIdx, newX, newY; | |
| let apply, amount, endY, endX; | |
| const maxShift = (mode === 'horizontal' ? width : height) * (displacementIntensity / 100); | |
| const readRow = (y, buffer) => { | |
| start = y * rowStride; | |
| for (i = 0; i < rowStride; i++) { | |
| buffer[i] = src[start + i]; | |
| } | |
| }; | |
| const writeRow = (y, buffer) => { | |
| start = y * rowStride; | |
| for (i = 0; i < rowStride; i++) { | |
| dst[start + i] = buffer[i]; | |
| } | |
| }; | |
| const readColumn = (x, buffer) => { | |
| offset = x << 2; | |
| for (y = 0; y < height; y++) { | |
| i = (y * width4) + offset; | |
| j = y << 2; | |
| buffer[j] = src[i]; | |
| buffer[j | 1] = src[i | 1]; | |
| buffer[j | 2] = src[i | 2]; | |
| buffer[j | 3] = src[i | 3]; | |
| } | |
| }; | |
| const writeColumn = (x, buffer) => { | |
| offset = x << 2; | |
| for (y = 0; y < height; y++) { | |
| i = (y * width4) + offset; | |
| j = y << 2; | |
| dst[i] = buffer[j]; | |
| dst[i | 1] = buffer[j | 1]; | |
| dst[i | 2] = buffer[j | 2]; | |
| dst[i | 3] = buffer[j | 3]; | |
| } | |
| }; | |
| if (mode === 'horizontal') { | |
| for (y = 0; y < height; y += displacementSize) { | |
| apply = rng() < displacementFrequency; | |
| amount = apply ? Math.floor(rng() * maxShift) * (rng() > 0.5 ? 1 : -1) : 0; | |
| //amount = apply ? Math.floor(rng() * displacementIntensity) * (rng() > 0.5 ? 1 : -1) : 0; | |
| endY = Math.min(y + displacementSize, height); | |
| for (dy = y; dy < endY; dy++) { | |
| for (x = 0; x < width; x++) { | |
| newX = (x + amount + width) % width; | |
| srcIdx = ((dy * width) + newX) << 2; | |
| dstIdx = ((dy * width) + x) << 2; | |
| dst[dstIdx] = src[srcIdx]; | |
| dst[dstIdx | 1] = src[srcIdx | 1]; | |
| dst[dstIdx | 2] = src[srcIdx | 2]; | |
| dst[dstIdx | 3] = src[srcIdx | 3]; | |
| } | |
| } | |
| } | |
| } else if (mode === 'vertical') { | |
| for (x = 0; x < width; x += displacementSize) { | |
| apply = rng() < displacementFrequency; | |
| //amount = apply ? Math.floor(rng() * displacementIntensity) * (rng() > 0.5 ? 1 : -1) : 0; | |
| amount = apply ? Math.floor(rng() * maxShift) * (rng() > 0.5 ? 1 : -1) : 0; | |
| const maxAmount = height; | |
| amount = Math.max(-maxAmount, Math.min(maxAmount, amount)); | |
| endX = Math.min(x + displacementSize, width); | |
| for (dx = x; dx < endX; dx++) { | |
| readColumn(dx, colBuffer); | |
| if (apply && amount !== 0) { | |
| for (y = 0; y < height; y++) { | |
| newY = ((y + amount) % height + height) % height; | |
| srcIdx = newY << 2; | |
| dstIdx = y << 2; | |
| tempColBuffer[dstIdx] = colBuffer[srcIdx]; | |
| tempColBuffer[dstIdx | 1] = colBuffer[srcIdx | 1]; | |
| tempColBuffer[dstIdx | 2] = colBuffer[srcIdx | 2]; | |
| tempColBuffer[dstIdx | 3] = colBuffer[srcIdx | 3]; | |
| } | |
| writeColumn(dx, tempColBuffer); | |
| } else { | |
| writeColumn(dx, colBuffer); | |
| } | |
| } | |
| } | |
| } | |
| return output; | |
| } | |
| function makeScaledDisplacementEffect(scale = 1) { | |
| return function(inputImageData, output) { | |
| const scaledParams = { | |
| ...displacementParams, | |
| displacementIntensity: displacementParams.displacementIntensity, // * scale, | |
| displacementSize: Math.max(1, Math.round(displacementParams.displacementSize * scale)), | |
| displacementFrequency: displacementParams.displacementFrequency // frequency is unitless | |
| }; | |
| const original = { ...displacementParams }; | |
| Object.assign(displacementParams, scaledParams); | |
| const result = applyDisplacement(inputImageData, output); | |
| Object.assign(displacementParams, original); | |
| return result; | |
| }; | |
| } | |
| function applyPixelSort(inputImageData, output) { | |
| if (!inputImageData || !pixelSortParams || !pixelSortParams.enablePixelSort) { | |
| return inputImageData; | |
| } | |
| const params = pixelSortParams; | |
| const direction = params.direction || 'horizontal'; | |
| const blockSize = params.blockSize || 32; | |
| const frequency = params.frequency !== undefined ? params.frequency : 0.7; | |
| const seed = params.seed || 0; | |
| const sortType = params.sortType || 'shuffle'; | |
| const width = inputImageData.width; | |
| const height = inputImageData.height; | |
| const src = inputImageData.data; | |
| const dst = output.data; | |
| const rng = mulberry32(seed); | |
| const randomCount = direction === 'horizontal' ? height : width; | |
| const skipRandom = new Float32Array(randomCount); | |
| for (let i = 0; i < randomCount; i++) { | |
| skipRandom[i] = rng(); | |
| } | |
| const frequencyThreshold = frequency; | |
| const maxBlockSize = Math.max(blockSize, 256); | |
| const sharedBlock = new Uint32Array(maxBlockSize); | |
| let rowStart, idx, actualSize, i, j, px, x, y; | |
| let r, g, b, a; | |
| if (direction === 'horizontal') { | |
| for (y = 0; y < height; y++) { | |
| rowStart = y * width * 4; | |
| if (skipRandom[y] > frequencyThreshold) { | |
| for (x = 0; x < width; x++) { | |
| idx = rowStart + x * 4; | |
| dst[idx] = src[idx]; | |
| dst[idx + 1] = src[idx + 1]; | |
| dst[idx + 2] = src[idx + 2]; | |
| dst[idx + 3] = src[idx + 3]; | |
| } | |
| continue; | |
| } | |
| for (x = 0; x < width; x += blockSize) { | |
| actualSize = Math.min(blockSize, width - x); | |
| for (j = 0; j < actualSize; j++) { | |
| idx = rowStart + (x + j) * 4; | |
| r = src[idx]; | |
| g = src[idx + 1]; | |
| b = src[idx + 2]; | |
| a = src[idx + 3]; | |
| sharedBlock[j] = (r << 24) | (g << 16) | (b << 8) | a; | |
| } | |
| if (sortType === 'shuffle') { | |
| for (i = actualSize - 1; i > 0; i--) { | |
| j = (rng() * (i + 1)) | 0; | |
| px = sharedBlock[i]; | |
| sharedBlock[i] = sharedBlock[j]; | |
| sharedBlock[j] = px; | |
| } | |
| } else { | |
| sharedBlock.subarray(0, actualSize).sort(); | |
| } | |
| for (j = 0; j < actualSize; j++) { | |
| idx = rowStart + (x + j) * 4; | |
| px = sharedBlock[j]; | |
| dst[idx] = (px >> 24) & 0xff; | |
| dst[idx + 1] = (px >> 16) & 0xff; | |
| dst[idx + 2] = (px >> 8) & 0xff; | |
| dst[idx + 3] = px & 0xff; | |
| } | |
| } | |
| } | |
| } else { | |
| for (x = 0; x < width; x++) { | |
| if (skipRandom[x] > frequencyThreshold) { | |
| for (y = 0; y < height; y++) { | |
| idx = (y * width + x) * 4; | |
| dst[idx] = src[idx]; | |
| dst[idx + 1] = src[idx + 1]; | |
| dst[idx + 2] = src[idx + 2]; | |
| dst[idx + 3] = src[idx + 3]; | |
| } | |
| continue; | |
| } | |
| for (y = 0; y < height; y += blockSize) { | |
| actualSize = Math.min(blockSize, height - y); | |
| for (j = 0; j < actualSize; j++) { | |
| idx = ((y + j) * width + x) * 4; | |
| r = src[idx]; | |
| g = src[idx + 1]; | |
| b = src[idx + 2]; | |
| a = src[idx + 3]; | |
| sharedBlock[j] = (r << 24) | (g << 16) | (b << 8) | a; | |
| } | |
| if (sortType === 'shuffle') { | |
| for (i = actualSize - 1; i > 0; i--) { | |
| j = (rng() * (i + 1)) | 0; | |
| px = sharedBlock[i]; | |
| sharedBlock[i] = sharedBlock[j]; | |
| sharedBlock[j] = px; | |
| } | |
| } else { | |
| sharedBlock.subarray(0, actualSize).sort(); | |
| } | |
| for (j = 0; j < actualSize; j++) { | |
| idx = ((y + j) * width + x) * 4; | |
| px = sharedBlock[j]; | |
| dst[idx] = (px >> 24) & 0xff; | |
| dst[idx + 1] = (px >> 16) & 0xff; | |
| dst[idx + 2] = (px >> 8) & 0xff; | |
| dst[idx + 3] = px & 0xff; | |
| } | |
| } | |
| } | |
| } | |
| return output; | |
| } | |
| function makeScaledPixelSortEffect(scale = 1) { | |
| return function(inputImageData, output) { | |
| const scaledParams = { | |
| ...pixelSortParams, | |
| blockSize: Math.max(1, Math.round(pixelSortParams.blockSize * scale)), | |
| }; | |
| const original = { ...pixelSortParams }; | |
| Object.assign(pixelSortParams, scaledParams); | |
| const result = applyPixelSort(inputImageData, output); | |
| Object.assign(pixelSortParams, original); | |
| return result; | |
| }; | |
| } | |
| function applyDataCorruption(inputImageData, output) { | |
| if (!inputImageData || !dataCorruptionParams.enableDataCorruption) { | |
| return inputImageData; | |
| } | |
| let blockSize, corruptionAmount, corruptionMode, seed = 0; | |
| let rng, width, height, src, dst; | |
| let totalBlocksX, totalBlocksY; | |
| let width4, blockSize4, src32, dst32; | |
| let blockX, blockY, xStart, yStart, xEnd, yEnd; | |
| let y, rowStart, rowEnd, idx, srcX, srcY, srcIdx; | |
| ({ blockSize, corruptionAmount, corruptionMode, seed } = dataCorruptionParams); | |
| rng = mulberry32(seed); | |
| width = inputImageData.width; | |
| height = inputImageData.height; | |
| src = inputImageData.data; | |
| dst = output.data; | |
| totalBlocksX = Math.ceil(width / blockSize); | |
| totalBlocksY = Math.ceil(height / blockSize); | |
| width4 = width << 2; | |
| blockSize4 = blockSize << 2; | |
| if ((src.length & 3) === 0 && (dst.length & 3) === 0) { | |
| src32 = new Uint32Array(src.buffer); | |
| dst32 = new Uint32Array(dst.buffer); | |
| dst32.set(src32); | |
| } else { | |
| dst.set(src); | |
| } | |
| for (blockY = 0; blockY < totalBlocksY; blockY++) { | |
| for (blockX = 0; blockX < totalBlocksX; blockX++) { | |
| if (rng() > corruptionAmount) continue; | |
| xStart = blockX * blockSize; | |
| yStart = blockY * blockSize; | |
| xEnd = Math.min(xStart + blockSize, width); | |
| yEnd = Math.min(yStart + blockSize, height); | |
| // Randomly pick a mode if "random" | |
| let effectiveMode = corruptionMode; | |
| if (corruptionMode === "random") { | |
| const modes = ["zero", "invert", "shift"]; | |
| effectiveMode = modes[(rng() * modes.length) | 0]; | |
| } | |
| for (y = yStart; y < yEnd; y++) { | |
| rowStart = (y * width + xStart) << 2; | |
| rowEnd = (y * width + xEnd) << 2; | |
| for (idx = rowStart; idx < rowEnd; idx += 4) { | |
| switch (effectiveMode) { | |
| case "rgb": | |
| dst[idx] = (rng() * 256) | 0; | |
| dst[idx + 1] = (rng() * 256) | 0; | |
| dst[idx + 2] = (rng() * 256) | 0; | |
| break; | |
| case "zero": | |
| dst[idx] = dst[idx + 1] = dst[idx + 2] = 0; | |
| break; | |
| case "invert": | |
| dst[idx] = 255 - dst[idx]; | |
| dst[idx + 1] = 255 - dst[idx + 1]; | |
| dst[idx + 2] = 255 - dst[idx + 2]; | |
| break; | |
| case "shift": | |
| srcX = (rng() * width) | 0; | |
| srcY = (rng() * height) | 0; | |
| srcIdx = (srcY * width + srcX) << 2; | |
| dst[idx] = src[srcIdx]; | |
| dst[idx + 1] = src[srcIdx + 1]; | |
| dst[idx + 2] = src[srcIdx + 2]; | |
| break; | |
| } | |
| // Restore original alpha | |
| dst[idx + 3] = src[idx + 3]; | |
| } | |
| } | |
| } | |
| } | |
| return output; | |
| } | |
| function makeScaledDataCorruptionEffect(scale = 1) { | |
| return function(inputImageData, output) { | |
| const scaledParams = { | |
| ...dataCorruptionParams, | |
| blockSize: Math.max(2, Math.round(dataCorruptionParams.blockSize * scale)), | |
| }; | |
| const original = { ...dataCorruptionParams }; | |
| Object.assign(dataCorruptionParams, scaledParams); | |
| const result = applyDataCorruption(inputImageData, output); | |
| Object.assign(dataCorruptionParams, original); | |
| return result; | |
| }; | |
| } | |
| // ------ Tweakpane Image I/O Folder ------ | |
| const imageIOFolder = pane.addFolder({ title: "Image I/O", expanded: true }); | |
| TweakpaneUtils.addImageUploader(imageIOFolder, { | |
| allowedUploadTypes: ['image/png', 'image/jpeg', 'image/webp'], | |
| buttonOptions: { title: 'Upload Image' }, | |
| onStart: (file) => { | |
| TweakpaneUtils.setEnabled(pane, false); | |
| console.log("Started upload for:", file.name); | |
| }, | |
| onFinish: (file, dataUrl) => { | |
| console.log("Finished uploading:", file.name); | |
| drawImageOnCanvas(dataUrl); | |
| TweakpaneUtils.setEnabled(pane, true); | |
| }, | |
| onError: (err, file) => { | |
| console.error("Upload error:", err); | |
| TweakpaneUtils.setEnabled(pane, true); | |
| }, | |
| onCancel: () => { | |
| console.log("User cancelled image selection."); | |
| TweakpaneUtils.setEnabled(pane, true); | |
| } | |
| }); | |
| const qualityMessage = TweakpaneUtils.addPersistentMessage(imageIOFolder, "Image Quality"); | |
| TweakpaneUtils.addImageDownloader(imageIOFolder, async () => { | |
| const imageData = await getDownloadImageData(); | |
| return { | |
| imageData, | |
| meta: { | |
| scaleFactor: originalImageMeta.scaleFactor, | |
| originalWidth: originalImageMeta.originalWidth, | |
| originalHeight: originalImageMeta.originalHeight | |
| } | |
| }; | |
| }, { | |
| filename: () => { | |
| return "output-image.png"; | |
| }, | |
| showStatus: true, | |
| formatOptions: { enabled: true, defaultFormat: "png" }, | |
| zipOptions: { enabled: false }, | |
| qualityOptions: { enabled: false }, | |
| onStart: () => TweakpaneUtils.setEnabled(pane, false), | |
| onFinish: () => TweakpaneUtils.setEnabled(pane, true), | |
| onError: (err) => { | |
| console.error("Download error:", err); | |
| TweakpaneUtils.setEnabled(pane, true); | |
| }, | |
| }); | |
| const settingsFolder = pane.addFolder({ title: "Settings" }); | |
| // ------ Tweakpane Palette Folder ------ | |
| const paletteFolder = settingsFolder.addFolder({ title: "Palette Reduction" }); | |
| paletteFolder.addBinding(paletteReductionParams, "enablePaletteReduction", { label: "Enable" }) | |
| .on("change", () => { | |
| if (originalImage?.src) drawImageOnCanvas(originalImage.src); | |
| }); | |
| paletteFolder.addBinding(paletteReductionParams, "paletteName", { | |
| label: "Palette", | |
| options: { | |
| "Game Boy": "gameboy", | |
| Firewatch: "firewatch", | |
| Desert: "desert", | |
| Lavender: "lavender", | |
| "Stranger Things": "strangerThings", | |
| Dawnbringer: "dawnbringer", | |
| "Black & White": "blackwhite", | |
| "Grayscale 4": "grayscale4", | |
| "Blade Runner": "bladeRunner", | |
| "Mad Max": "madMax", | |
| Matrix: "matrix", | |
| "Tron Legacy": "tronLegacy", | |
| Drive: "drive", | |
| Akira: "akira", | |
| Vaporwave: "vaporwave", | |
| "Miami Vice": "miamiVice", | |
| Lofi: "lofi", | |
| NES: "nes", | |
| "Glitch Core": "glitchCore", | |
| Acid: "acid" | |
| } | |
| }).on("change", () => { | |
| if (paletteReductionParams.enablePaletteReduction && originalImage?.src) { | |
| drawImageOnCanvas(originalImage.src); | |
| } | |
| }); | |
| paletteFolder.addBinding(paletteReductionParams, "distanceMode", { | |
| label: "Distance", | |
| options: { | |
| Fast: "fast", | |
| Accurate: "accurate", | |
| Perceptual: "perceptual" | |
| } | |
| }).on("change", () => { | |
| if (paletteReductionParams.enablePaletteReduction && originalImage?.src) { | |
| drawImageOnCanvas(originalImage.src); | |
| } | |
| }); | |
| paletteFolder.addBinding(paletteReductionParams, "useDithering", { | |
| label: "Dithering" | |
| }).on("change", () => { | |
| if (paletteReductionParams.enablePaletteReduction && originalImage?.src) { | |
| drawImageOnCanvas(originalImage.src); | |
| } | |
| }); | |
| // ------ Tweakpane Color Shift Folder ------ | |
| const colorShiftFolder = settingsFolder.addFolder({ title: "Color Shift" }); | |
| colorShiftFolder.addBinding(colorShiftParams, "enableColorShift", { label: "Enable" }) | |
| .on("change", ev => { | |
| if (!effectManager) return; | |
| effectManager.setEnabled("colorShift", ev.value); | |
| safeUpdate("colorShift"); | |
| }); | |
| colorShiftFolder.addBinding(colorShiftParams, "useUniformShift", { label: "Uniform Shift" }) | |
| .on("change", () => { | |
| setupColorShiftInputs(); | |
| safeUpdate("colorShift"); | |
| }); | |
| colorShiftFolder.addBinding(colorShiftParams, "intensity", { | |
| label: "Intensity", | |
| min: 0, | |
| max: 1 | |
| }).on("change", () => safeUpdate("colorShift")); | |
| let colorShiftBindings = []; | |
| function setupColorShiftInputs() { | |
| colorShiftBindings.forEach(b => b.dispose()); | |
| colorShiftBindings = []; | |
| if (colorShiftParams.useUniformShift) { | |
| colorShiftBindings.push( | |
| colorShiftFolder.addBinding(colorShiftParams, "shiftAmount", { | |
| label: "Shift Amount", | |
| min: -100, | |
| max: 100 | |
| }).on("change", () => safeUpdate("colorShift")) | |
| ); | |
| } else { | |
| colorShiftBindings.push( | |
| colorShiftFolder.addBinding(colorShiftParams, "redShift", { | |
| label: "Red Shift", | |
| min: -100, | |
| max: 100 | |
| }).on("change", () => safeUpdate("colorShift")), | |
| colorShiftFolder.addBinding(colorShiftParams, "greenShift", { | |
| label: "Green Shift", | |
| min: -100, | |
| max: 100 | |
| }).on("change", () => safeUpdate("colorShift")), | |
| colorShiftFolder.addBinding(colorShiftParams, "blueShift", { | |
| label: "Blue Shift", | |
| min: -100, | |
| max: 100 | |
| }).on("change", () => safeUpdate("colorShift")) | |
| ); | |
| } | |
| safeUpdate("colorShift"); | |
| } | |
| setupColorShiftInputs(); | |
| // ------ Tweakpane Wave Deform Folder ------ | |
| const waveDeformFolder = settingsFolder.addFolder({ title: "Wave Deform" }); | |
| waveDeformFolder.addBinding(waveDeformParams, "enableWaveDeform", { label: "Enable" }) | |
| .on("change", (ev) => { | |
| if (!effectManager) return; | |
| effectManager.setEnabled("waveDeform", ev.value); | |
| safeUpdate("waveDeform"); | |
| }); | |
| waveDeformFolder.addBinding(waveDeformParams, "direction", { | |
| label: "Direction", | |
| options: { | |
| Horizontal: "horizontal", | |
| Vertical: "vertical" | |
| } | |
| }).on("change", () => safeUpdate("waveDeform")); | |
| waveDeformFolder.addBinding(waveDeformParams, "amplitude", { | |
| label: "Amplitude", | |
| min: -100, | |
| max: 100 | |
| }).on("change", () => safeUpdate("waveDeform")); | |
| const frequencyBinding = waveDeformFolder.addBinding(waveDeformParams, "frequency", { | |
| label: "Frequency", | |
| min: 0.01, | |
| max: 1, | |
| step: 0.01 | |
| }).on("change", () => safeUpdate("waveDeform")); | |
| const phaseBinding = waveDeformFolder.addBinding(waveDeformParams, "phase", { | |
| label: "Phase", | |
| min: 0, | |
| max: Math.PI * 2, | |
| step: 0.1 | |
| }).on("change", () => safeUpdate("waveDeform")); | |
| waveDeformFolder.addBinding(waveDeformParams, "useNoise", { label: "Use Noise" }) | |
| .on("change", () => { | |
| const usingNoise = waveDeformParams.useNoise; | |
| frequencyBinding.hidden = usingNoise; | |
| phaseBinding.hidden = usingNoise; | |
| safeUpdate("waveDeform"); | |
| }); | |
| // Initial visibility state (in case useNoise = true on load) | |
| frequencyBinding.hidden = waveDeformParams.useNoise; | |
| phaseBinding.hidden = waveDeformParams.useNoise; | |
| // ------ Tweakpane Displacement Folder ------ | |
| const displacementFolder = settingsFolder.addFolder({ title: "Displacement" }); | |
| displacementFolder.addBinding(displacementParams, "enableDisplacement", { label: "Enable" }) | |
| .on("change", ev => { | |
| if (!effectManager) return; | |
| effectManager.setEnabled("displacement", ev.value); | |
| safeUpdate("displacement"); | |
| }); | |
| displacementFolder.addBinding(displacementParams, "mode", { | |
| label: "Mode", | |
| options: { Horizontal: "horizontal", Vertical: "vertical" } | |
| }).on("change", () => safeUpdate("displacement")); | |
| displacementFolder.addBinding(displacementParams, "displacementIntensity", { | |
| label: "Max Shift", | |
| min: -50, | |
| max: 50, | |
| step: 1, | |
| format: (v) => `${v}%`, | |
| }).on("change", ev => { | |
| safeUpdate("displacement"); | |
| }); | |
| displacementFolder.addBinding(displacementParams, "displacementSize", { | |
| label: "Band Size", | |
| min: 1, | |
| max: 50 | |
| }).on("change", () => safeUpdate("displacement")); | |
| displacementFolder.addBinding(displacementParams, "displacementFrequency", { | |
| label: "Frequency", | |
| min: 0, | |
| max: 1 | |
| }).on("change", () => safeUpdate("displacement")); | |
| // ------ Tweakpane Pixel Sort Folder ------ | |
| const pixelSortFolder = settingsFolder.addFolder({ title: 'Pixel Sort (CPU-Intensive)' }); | |
| pixelSortFolder.addBinding(pixelSortParams, 'enablePixelSort', { label: 'Enable' }) | |
| .on('change', (ev) => { | |
| if (!effectManager) return; | |
| effectManager.setEnabled('pixelSort', ev.value); | |
| safeUpdate('pixelSort'); | |
| }); | |
| pixelSortFolder.addBinding(pixelSortParams, 'direction', { | |
| label: 'Direction', | |
| options: { | |
| Horizontal: 'horizontal', | |
| Vertical: 'vertical' | |
| } | |
| }).on('change', () => { | |
| safeUpdate('pixelSort'); | |
| }); | |
| pixelSortFolder.addBinding(pixelSortParams, 'blockSize', { | |
| label: 'Block Size', | |
| min: 1, | |
| max: 50, | |
| step: 1 | |
| }).on('change', () => { | |
| safeUpdate('pixelSort'); | |
| }); | |
| pixelSortFolder.addBinding(pixelSortParams, 'frequency', { | |
| label: 'Frequency', | |
| min: 0, | |
| max: 1, | |
| step: 0.01 | |
| }).on('change', () => { | |
| safeUpdate('pixelSort'); | |
| }); | |
| pixelSortFolder.addBinding(pixelSortParams, 'sortType', { | |
| label: 'Sort Type', | |
| options: { | |
| Shuffle: 'shuffle', | |
| Sort: 'sort' | |
| } | |
| }).on('change', () => { | |
| safeUpdate("pixelSort"); | |
| }); | |
| // ------ Tweakpane Data Corruption Folder ------ | |
| const dataCorruptionFolder = settingsFolder.addFolder({ title: "Data Corruption" }); | |
| dataCorruptionFolder.addBinding(dataCorruptionParams, "enableDataCorruption", { label: "Enable" }) | |
| .on("change", ev => { | |
| if (!effectManager) return; | |
| effectManager.setEnabled("dataCorruption", ev.value); | |
| safeUpdate("dataCorruption"); | |
| }); | |
| dataCorruptionFolder.addBinding(dataCorruptionParams, "corruptionMode", { | |
| label: "Mode", | |
| options: { | |
| Random: "random", | |
| Zero: "zero", | |
| Invert: "invert", | |
| Shift: "shift" | |
| } | |
| }).on("change", () => safeUpdate("dataCorruption")); | |
| dataCorruptionFolder.addBinding(dataCorruptionParams, "blockSize", { | |
| label: "Block Size", | |
| min: 2, | |
| max: 64, | |
| step: 1 | |
| }).on("change", () => safeUpdate("dataCorruption")); | |
| dataCorruptionFolder.addBinding(dataCorruptionParams, "corruptionAmount", { | |
| label: 'Probability', | |
| min: 0, | |
| max: 1, | |
| step: 0.01, | |
| format: (v) => `${Math.round(v * 100)}%`, | |
| }).on("change", () => safeUpdate("dataCorruption")); | |
| // ------ Tweakpane Demo Images Folder ------ | |
| const demoImages = TweakpaneUtils.addDemoImages( | |
| pane, | |
| (loadedImage) => { | |
| console.log("Demo Image Loaded:", loadedImage); | |
| drawImageOnCanvas(loadedImage); | |
| TweakpaneUtils.setEnabled(pane, true); | |
| }, { | |
| baseURL: "https://www.lessrain.com/dev/images-2025/ai/lr-demo-img-2025-", | |
| totalImages: 330, | |
| thumbnailClass: "tp-demo-thumbnails", | |
| thumbnailExtensions: ["png"], | |
| imageExtensions: ["jpg", "webp", "png"], | |
| folderOptions: { title: "Demo Images", expanded: true }, | |
| ordered: true, | |
| startIndex: 195, | |
| onThumbnailClick: () => { | |
| TweakpaneUtils.setEnabled(pane, false); | |
| } | |
| } | |
| ); | |
| TweakpaneUtils.enableAccordion(pane, ["Demo Images", "Settings"], "Settings"); | |
| const isCodePen = document.referrer.includes("codepen.io"); | |
| const hostDomains = isCodePen ? ["codepen.io"] : []; | |
| hostDomains.push(window.location.hostname); | |
| const links = document.getElementsByTagName("a"); | |
| LR.utils.urlUtils.validateLinks(links, hostDomains); | |
| TweakpaneUtils.setEnabled(pane, false); | |
| window.addEventListener("resize", resizeCanvas); | |
| demoImages.loadImageIndex(0); | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <script src="https://assets.codepen.io/573855/lr-utils.js?v=6"></script> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| :root { | |
| --dark-color-h: 334.29; | |
| --dark-color-s: 32.03%; | |
| --dark-color-l: 30%; | |
| --light-color-h: 19.2; | |
| --light-color-s: 30.86%; | |
| --light-color-l: 84.12%; | |
| --dark-color: hsl(var(--dark-color-h), var(--dark-color-s), var(--dark-color-l)); | |
| --darker-trans-color: hsla(var(--dark-color-h), var(--dark-color-s), calc(var(--dark-color-l) - 10%), 0.75); | |
| --light-color: hsl(var(--light-color-h), var(--light-color-s), var(--light-color-l)); | |
| --bg-color: var(--dark-color); | |
| --text-color: var(--light-color); | |
| --resources-bg-color: var(--darker-trans-color); | |
| --resources-active-color: color-mix(in srgb, var(--light-color) 75%, transparent); | |
| --resources-color: var(--text-color); | |
| --tp-base-background-color: #432331; | |
| --tp-base-shadow-color: #432331; | |
| --tp-button-background-color: #e3d2ca; | |
| --tp-button-background-color-active: #f5eeeb; | |
| --tp-button-background-color-focus: #ece0db; | |
| --tp-button-background-color-hover: #e3d2ca; | |
| --tp-button-foreground-color: #432331; | |
| --tp-container-background-color: #533540; | |
| --tp-container-background-color-active: #6b4f57; | |
| --tp-container-background-color-focus: #634650; | |
| --tp-container-background-color-hover: #5b3d48; | |
| --tp-container-foreground-color: #e3d2ca; | |
| --tp-groove-foreground-color: #533540; | |
| --tp-input-background-color: #533540; | |
| --tp-input-background-color-active: #6b4f57; | |
| --tp-input-background-color-focus: #634650; | |
| --tp-input-background-color-hover: #5b3d48; | |
| --tp-input-foreground-color: #e3d2ca; | |
| --tp-label-foreground-color: #b39e9c; | |
| --tp-monitor-background-color: #381d29; | |
| --tp-monitor-foreground-color: #b39e9c; | |
| } | |
| *, | |
| ::after, | |
| ::before { | |
| border-style: solid; | |
| border-width: 0; | |
| box-sizing: border-box; | |
| } | |
| * { | |
| -webkit-tap-highlight-color: rgba(0, 0, 0, 0); | |
| } | |
| body { | |
| align-items: center; | |
| background: var(--bg-color); | |
| color: var(--text-color); | |
| display: grid; | |
| font-family: Inter, Roboto, "Helvetica Neue", "Arial Nova", "Nimbus Sans", Arial, sans-serif; | |
| justify-items: center; | |
| line-height: 1.5; | |
| margin: 0; | |
| margin-inline: 0; | |
| min-height: 100vh; | |
| place-items: center; | |
| width: 100%; | |
| } | |
| canvas { | |
| --canvas-offset: 4rem; | |
| --max-canvas-width: 64rem; | |
| --max-canvas-height: 64rem; | |
| background: var(--darker-trans-color); | |
| border-radius: 2%; | |
| box-shadow: 0 .125rem .25rem var(--darker-trans-color); | |
| display: block; | |
| height: calc(min(100dvw, 100dvh) - var(--canvas-offset)); | |
| max-block-size: var(--max-canvas-height); | |
| max-inline-size: var(--max-canvas-width); | |
| pointer-events: none; | |
| position: relative; | |
| width: calc(min(100dvw, 100dvh) - var(--canvas-offset)); | |
| width: calc(min(100dvw, 100dvh) - var(--canvas-offset) - env(safe-area-inset-bottom)); | |
| } | |
| .tp-dfwv { | |
| padding: 0 0 3rem; | |
| } | |
| .tp-fldv:not(.tp-fldv-expanded)>.tp-fldv_c { | |
| opacity: 0 !important; | |
| } | |
| .tp-lblv_l { | |
| pointer-events: none; | |
| -webkit-user-select: none; | |
| -moz-user-select: none; | |
| user-select: none; | |
| } | |
| .tp-fldv.tp-fldv-expanded>.tp-fldv_c { | |
| opacity: 1 !important; | |
| transition-delay: 0s, .2s !important; | |
| transition-duration: .2s, .2s !important; | |
| transition-property: height, opacity !important; | |
| } | |
| .tp-fldv_c:has(.tp-demo-thumbnails) { | |
| margin-left: .25rem; | |
| padding-left: 0; | |
| } | |
| .tp-demo-thumbnails { | |
| display: grid; | |
| gap: .1875rem; | |
| grid-gap: .1875rem; | |
| grid-template-columns: repeat(4, 1fr); | |
| padding: 0 .25rem .0625rem; | |
| width: 100%; | |
| } | |
| .tp-demo-thumbnail { | |
| align-items: center; | |
| aspect-ratio: 1; | |
| background-color: var(--cnt-bg); | |
| border-radius: .25rem; | |
| cursor: pointer; | |
| display: grid; | |
| height: auto; | |
| justify-items: center; | |
| overflow: hidden; | |
| place-items: center; | |
| -webkit-user-select: none; | |
| -moz-user-select: none; | |
| user-select: none; | |
| width: 100%; | |
| } | |
| .tp-demo-thumbnail:after { | |
| aspect-ratio: 1; | |
| border: .125rem solid transparent; | |
| border-radius: .25rem; | |
| content: ""; | |
| display: block; | |
| grid-area: 1/-1; | |
| pointer-events: none; | |
| position: relative; | |
| transition: border .3s; | |
| width: 100%; | |
| } | |
| .tp-demo-thumbnail:active:after { | |
| border: .125rem solid var(--cnt-fg); | |
| } | |
| .tp-demo-thumbnail img { | |
| border-radius: .25rem; | |
| display: block; | |
| grid-area: 1/-1; | |
| height: auto; | |
| -o-object-fit: cover; | |
| object-fit: cover; | |
| width: 100%; | |
| } | |
| .resources-layer { | |
| bottom: 0; | |
| display: block; | |
| position: fixed; | |
| right: 0; | |
| z-index: 1000; | |
| } | |
| .resources { | |
| background: var(--resources-bg-color); | |
| display: grid; | |
| font-family: Inter, Roboto, "Helvetica Neue", "Arial Nova", "Nimbus Sans", Arial, sans-serif; | |
| font-size: .6875rem; | |
| font-weight: 300; | |
| grid-auto-flow: column; | |
| line-height: 1.3; | |
| padding: .5rem; | |
| pointer-events: auto; | |
| } | |
| .resources a { | |
| align-content: center; | |
| display: grid; | |
| justify-content: center; | |
| padding: 0 .5rem; | |
| place-content: center; | |
| transition: color .2s ease-in-out; | |
| } | |
| .resources a, | |
| .resources a:visited { | |
| color: var(--resources-color); | |
| } | |
| .resources a:active, | |
| .resources a:focus-visible { | |
| color: var(--resources-active-color); | |
| } | |
| .resources a:focus-visible { | |
| outline: none; | |
| } | |
| .resources a:not(:first-child) { | |
| border-inline-start: thin solid currentColor; | |
| } | |
| @media (hover:hover) and (pointer:fine) { | |
| .tp-demo-thumbnail:hover:not(:disabled):after { | |
| border: .125rem solid var(--cnt-fg); | |
| } | |
| .resources a:active:not(:hover), | |
| .resources a:hover { | |
| color: var(--resources-active-color); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment