From eb854553eb8ecbbb83b5f9627c39537640425fdd Mon Sep 17 00:00:00 2001 From: Angel Wang Date: Sat, 20 Jun 2026 13:29:50 -0600 Subject: [PATCH] feat: rewrite in ts, add deno --- deno.json | 9 ++++ deno.lock | 31 ++++++++++++ grade.mjs | 107 --------------------------------------- grade.mts | 149 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ index.mts | 32 ++++++++++++ 5 files changed, 221 insertions(+), 107 deletions(-) create mode 100644 deno.json create mode 100644 deno.lock delete mode 100755 grade.mjs create mode 100755 grade.mts create mode 100755 index.mts diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..c3fd439 --- /dev/null +++ b/deno.json @@ -0,0 +1,9 @@ +{ + "tasks": { + "dev": "zx index.mts" + }, + "imports": { + "@std/assert": "jsr:@std/assert@1", + "zx": "npm:zx@^8.8.5" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..7dde75a --- /dev/null +++ b/deno.lock @@ -0,0 +1,31 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@1": "1.0.19", + "jsr:@std/internal@^1.0.12": "1.0.12", + "npm:zx@^8.8.5": "8.8.5" + }, + "jsr": { + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/internal@1.0.12": { + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" + } + }, + "npm": { + "zx@8.8.5": { + "integrity": "sha512-SNgDF5L0gfN7FwVOdEFguY3orU5AkfFZm9B5YSHog/UDHv+lvmd82ZAsOenOkQixigwH2+yyH198AwNdKhj+RA==", + "bin": true + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/assert@1", + "npm:zx@^8.8.5" + ] + } +} diff --git a/grade.mjs b/grade.mjs deleted file mode 100755 index 71336cc..0000000 --- a/grade.mjs +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env zx -import "fs"; - -await $`rm -f cache.json`; -// await $`lutgen apply -p catppuccin-latte -l 8 -L 0.001 -P -r 21.5 ./orig/* -o ./graded`; -cd("./graded"); - -const files = await fs.promises.opendir("."); - -let json = {}; - -// set up the data storage -let lightnessData = []; -for (var i = 0; i < 10; i += 2) { - lightnessData.push({ lightness: i / 10, paths: [] }); -} - -let chromaData = []; -for (var i = 0; i < 10; i += 2) { - chromaData.push({ chroma: i / 100, paths: [] }); -} - -let hueData = []; -for (var i = 0; i < 360; i += 90) { - hueData.push({ hue: i, paths: [] }); -} - -// analyze the files -for await (const file of files) { - const stat = await fs.promises.stat(file.name); - if (stat.isFile()) { - let output = - await $`magick ${file.name} -colorspace Oklch -kmeans 10 -format "%c" histogram:info:`.then( - (s) => - s.stdout - .split("\n") - .map((substr) => substr.trim()) - .filter((n) => n), - ); - - if (output.length < 5) { - // do it again but with more violence - console.log( - `-kmeans 10 of ${file.name} didn't give enough colours, trying again with a higher value...`, - ); - output = - await $`magick ${file.name} -colorspace Oklch -kmeans 40 -format "%c" histogram:info:`.then( - (s) => - s.stdout - .split("\n") - .map((substr) => substr.trim()) - .filter((n) => n), - ); - } - - let colours = output.map((entry) => { - let arr = entry.split(" "); - arr.pop(); - // [# of pixels, oklab colour, hex code] - return [parseInt(arr[0]), arr[1].replace(/[()]/g, "").split(","), arr[2]]; - }); - - colours.sort((a, b) => b[0] - a[0]); - - const dominantColour = colours[0][1]; - console.log( - `Got oklch(${dominantColour}) as dominant colour of ${file.name}!`, - ); - const lightness = dominantColour[0]; - const chroma = dominantColour[1]; - const hue = dominantColour[2]; - - lightnessData.forEach((n) => { - let targetLightness = Math.floor(lightness * 5) / 5; - if (n.lightness == targetLightness) { - n.paths.push(file.name); - return; - } - }); - - chromaData.forEach((n) => { - let targetChroma = Math.floor(chroma * 50) / 50; - if (n.chroma == targetChroma) { - n.paths.push(file.name); - return; - } - }); - - hueData.forEach((n) => { - let targetHue = Math.floor(hue / 90) * 90; - if (n.hue == targetHue) { - n.paths.push(file.name); - return; - } - }); - - json["lightnessData"] = lightnessData; - json["chromaData"] = chromaData; - json["hueData"] = hueData; - - fs.writeFile("./cache.json", JSON.stringify(json), err => {}) - - // past 0.3, the image is way too dark to have any discernable colour - } -} - - diff --git a/grade.mts b/grade.mts new file mode 100755 index 0000000..09482fd --- /dev/null +++ b/grade.mts @@ -0,0 +1,149 @@ +#!/usr/bin/env zx +/// + +export interface Config { + imageDir: string + lightnessStep: number + chromaStep: number + hueStep: number + preAnalysisCommands: string[] +} + +export class ImageAnalysisData { + lightnessData: { lightness: number, paths: string[] }[] + chromaData: { chroma: number, paths: string[] }[] + hueData: { hue: number, paths: string[] }[] + + constructor(lightnessStep: number, chromaStep: number, hueStep: number) { + this.lightnessData = [] + this.chromaData = [] + this.hueData = [] + + for (let i = 0; i < 1; i += lightnessStep) { + i = roundToStep(i, lightnessStep) // mitigate floating point precision errors + this.lightnessData.push({ lightness: i, paths: [] }) + } + + for (let i = 0; i < 1; i += chromaStep) { + i = roundToStep(i, chromaStep) + this.chromaData.push({ chroma: i, paths: [] }) + } + for (let i = 0; i < 360; i += hueStep) { + this.hueData.push({ hue: i, paths: [] }) + } + } +} + +export function setupConfig(configDir: string) { + const defaultConfig : Config = { + imageDir: `${os.homedir()}/Pictures`, + lightnessStep: 0.1, + chromaStep: 0.01, + hueStep: 90, + preAnalysisCommands: [] + } + + fs.writeFile(`${configDir}/config.jsonc`, JSON.stringify(defaultConfig)) + console.info(`Generated default config at ${configDir}/config.jsonc!`) + return defaultConfig +} + +export function runPreAnalysis(hooks: string[]) { + hooks.forEach(async (hook) => { + await $`${hook}` // Scary! + }) +} + +export async function analyzeImages(imageDir: string, analysisOutput: ImageAnalysisData, outputPath: string, lightnessStep: number, chromaStep: number, hueStep: number) { + const images = await fs.promises.opendir(`${imageDir}`) + // await fs.rm(`${}/cache.json`) + + for await (const image of images) { + const path = imageDir + image.name + const stat = await fs.promises.stat(path) + if (stat.isFile()) { + let output = + await $`magick ${path} -colorspace Oklch -kmeans 10 -format "%c" histogram:info:`.then( + (s) => + s.stdout + .split("\n") + .map((substr) => substr.trim()) + .filter((n) => n), + ) as string[] + + if (output.length < 5) { + // do it again but with more violence + console.log( + `-kmeans 10 of ${image.name} didn't give enough colours, trying again with a higher value...`, + ) + output = + await $`magick ${path} -colorspace Oklch -kmeans 40 -format "%c" histogram:info:`.then( + (s) => + s.stdout + .split("\n") // split the output by newlines + .map((substr) => substr.trim()) // trim the whitespace on each line + .filter((n) => n), // and then filter out any blank elements generated + ) + } + + const colours = output.map((entry) => { + const arr = entry.split(" ") + arr.pop() + // [# of pixels, oklab colour, hex code] + return { + pixels: parseInt(arr[0]), + oklab: arr[1].replace(/[()]/g, "").split(","), // (0,0,0) -> Array [0, 0, 0] + hex: arr[2] + } + }) + + colours.sort((a, b) => (b.pixels) - (a.pixels)) + + const dominantColour = colours[0].oklab + + console.log( + `Got oklch(${dominantColour}) as dominant colour of ${image.name}!`, + ) + const lightness = Number(dominantColour[0]) + const chroma = Number(dominantColour[1]) + const hue = Number(dominantColour[2]) + + analysisOutput.lightnessData.forEach((n) => { + const targetLightness = floorToStep(lightness, lightnessStep) + if (n.lightness == targetLightness) { + n.paths.push(path) + return + } + }) + + analysisOutput.chromaData.forEach((n) => { + const targetChroma = floorToStep(chroma, chromaStep) + if (n.chroma == targetChroma) { + n.paths.push(path) + return + } + }) + + analysisOutput.hueData.forEach((n) => { + const targetHue = floorToStep(hue, hueStep) + if (n.hue == targetHue) { + n.paths.push(path) + return + } + }) + + // flush every time we finish analyzing a file, because it just takes so long + fs.writeFile(outputPath, JSON.stringify(analysisOutput), (err: Error) => { + if (err) throw new Error(err.message) + }) + } + } +} + +function floorToStep(num: number, step: number) { + return Math.floor(num / step) * step +} + +function roundToStep(num: number, step: number) { + return Math.round(num / step) * step +} \ No newline at end of file diff --git a/index.mts b/index.mts new file mode 100755 index 0000000..9c6db6e --- /dev/null +++ b/index.mts @@ -0,0 +1,32 @@ +#!/usr/bin/env zx +/// + +import { analyzeImages, Config, ImageAnalysisData, runPreAnalysis, setupConfig } from "./grade.mts"; + +const configDir = process.env.XDG_CONFIG_HOME ? `${process.env.XDG_CONFIG_HOME}/rainwall` : `${os.homedir()}/.config/rainwall` +const cacheDir = process.env.XDG_CACHE_HOME ? `${process.env.XDG_CACHE_HOME}/rainwall` : `${os.homedir()}/.cache/rainwall` + +fs.mkdir(configDir) +fs.mkdir(cacheDir) + +let config : Config | undefined + + +fs.readFile(`${configDir}/config.jsonc`, (err: Error, data: string) => { + if (err) { + console.warn(`Got ${err.message} when trying to load config, generating a new one...`) + config = setupConfig(configDir) + } else { + config = JSON.parse(data) + } +}) + +if (config == undefined) { + throw new Error("Couldn't initialize config!") +} + +let imageData = new ImageAnalysisData(config.lightnessStep, config.chromaStep, config.hueStep) + +runPreAnalysis(config.preAnalysisCommands) + +await analyzeImages(config.imageDir, imageData, `${cacheDir}/cache.json`, config.lightnessStep, config.chromaStep, config.hueStep) \ No newline at end of file -- 2.51.2