🌈️ apply a wallpaper based on the weather outside
0

Configure Feed

Select the types of activity you want to include in your feed.

fix: floating point errors, fs read/write errors

Angel Wang (Jun 20, 2026, 4:35 PM -0600) 6ff754ec eb854553

+191 -181
-149
grade.mts
··· 1 - #!/usr/bin/env zx 2 - /// <reference types="zx/globals" /> 3 - 4 - export interface Config { 5 - imageDir: string 6 - lightnessStep: number 7 - chromaStep: number 8 - hueStep: number 9 - preAnalysisCommands: string[] 10 - } 11 - 12 - export class ImageAnalysisData { 13 - lightnessData: { lightness: number, paths: string[] }[] 14 - chromaData: { chroma: number, paths: string[] }[] 15 - hueData: { hue: number, paths: string[] }[] 16 - 17 - constructor(lightnessStep: number, chromaStep: number, hueStep: number) { 18 - this.lightnessData = [] 19 - this.chromaData = [] 20 - this.hueData = [] 21 - 22 - for (let i = 0; i < 1; i += lightnessStep) { 23 - i = roundToStep(i, lightnessStep) // mitigate floating point precision errors 24 - this.lightnessData.push({ lightness: i, paths: [] }) 25 - } 26 - 27 - for (let i = 0; i < 1; i += chromaStep) { 28 - i = roundToStep(i, chromaStep) 29 - this.chromaData.push({ chroma: i, paths: [] }) 30 - } 31 - for (let i = 0; i < 360; i += hueStep) { 32 - this.hueData.push({ hue: i, paths: [] }) 33 - } 34 - } 35 - } 36 - 37 - export function setupConfig(configDir: string) { 38 - const defaultConfig : Config = { 39 - imageDir: `${os.homedir()}/Pictures`, 40 - lightnessStep: 0.1, 41 - chromaStep: 0.01, 42 - hueStep: 90, 43 - preAnalysisCommands: [] 44 - } 45 - 46 - fs.writeFile(`${configDir}/config.jsonc`, JSON.stringify(defaultConfig)) 47 - console.info(`Generated default config at ${configDir}/config.jsonc!`) 48 - return defaultConfig 49 - } 50 - 51 - export function runPreAnalysis(hooks: string[]) { 52 - hooks.forEach(async (hook) => { 53 - await $`${hook}` // Scary! 54 - }) 55 - } 56 - 57 - export async function analyzeImages(imageDir: string, analysisOutput: ImageAnalysisData, outputPath: string, lightnessStep: number, chromaStep: number, hueStep: number) { 58 - const images = await fs.promises.opendir(`${imageDir}`) 59 - // await fs.rm(`${}/cache.json`) 60 - 61 - for await (const image of images) { 62 - const path = imageDir + image.name 63 - const stat = await fs.promises.stat(path) 64 - if (stat.isFile()) { 65 - let output = 66 - await $`magick ${path} -colorspace Oklch -kmeans 10 -format "%c" histogram:info:`.then( 67 - (s) => 68 - s.stdout 69 - .split("\n") 70 - .map((substr) => substr.trim()) 71 - .filter((n) => n), 72 - ) as string[] 73 - 74 - if (output.length < 5) { 75 - // do it again but with more violence 76 - console.log( 77 - `-kmeans 10 of ${image.name} didn't give enough colours, trying again with a higher value...`, 78 - ) 79 - output = 80 - await $`magick ${path} -colorspace Oklch -kmeans 40 -format "%c" histogram:info:`.then( 81 - (s) => 82 - s.stdout 83 - .split("\n") // split the output by newlines 84 - .map((substr) => substr.trim()) // trim the whitespace on each line 85 - .filter((n) => n), // and then filter out any blank elements generated 86 - ) 87 - } 88 - 89 - const colours = output.map((entry) => { 90 - const arr = entry.split(" ") 91 - arr.pop() 92 - // [# of pixels, oklab colour, hex code] 93 - return { 94 - pixels: parseInt(arr[0]), 95 - oklab: arr[1].replace(/[()]/g, "").split(","), // (0,0,0) -> Array [0, 0, 0] 96 - hex: arr[2] 97 - } 98 - }) 99 - 100 - colours.sort((a, b) => (b.pixels) - (a.pixels)) 101 - 102 - const dominantColour = colours[0].oklab 103 - 104 - console.log( 105 - `Got oklch(${dominantColour}) as dominant colour of ${image.name}!`, 106 - ) 107 - const lightness = Number(dominantColour[0]) 108 - const chroma = Number(dominantColour[1]) 109 - const hue = Number(dominantColour[2]) 110 - 111 - analysisOutput.lightnessData.forEach((n) => { 112 - const targetLightness = floorToStep(lightness, lightnessStep) 113 - if (n.lightness == targetLightness) { 114 - n.paths.push(path) 115 - return 116 - } 117 - }) 118 - 119 - analysisOutput.chromaData.forEach((n) => { 120 - const targetChroma = floorToStep(chroma, chromaStep) 121 - if (n.chroma == targetChroma) { 122 - n.paths.push(path) 123 - return 124 - } 125 - }) 126 - 127 - analysisOutput.hueData.forEach((n) => { 128 - const targetHue = floorToStep(hue, hueStep) 129 - if (n.hue == targetHue) { 130 - n.paths.push(path) 131 - return 132 - } 133 - }) 134 - 135 - // flush every time we finish analyzing a file, because it just takes so long 136 - fs.writeFile(outputPath, JSON.stringify(analysisOutput), (err: Error) => { 137 - if (err) throw new Error(err.message) 138 - }) 139 - } 140 - } 141 - } 142 - 143 - function floorToStep(num: number, step: number) { 144 - return Math.floor(num / step) * step 145 - } 146 - 147 - function roundToStep(num: number, step: number) { 148 - return Math.round(num / step) * step 149 - }
-32
index.mts
··· 1 - #!/usr/bin/env zx 2 - /// <reference types="zx/globals" /> 3 - 4 - import { analyzeImages, Config, ImageAnalysisData, runPreAnalysis, setupConfig } from "./grade.mts"; 5 - 6 - const configDir = process.env.XDG_CONFIG_HOME ? `${process.env.XDG_CONFIG_HOME}/rainwall` : `${os.homedir()}/.config/rainwall` 7 - const cacheDir = process.env.XDG_CACHE_HOME ? `${process.env.XDG_CACHE_HOME}/rainwall` : `${os.homedir()}/.cache/rainwall` 8 - 9 - fs.mkdir(configDir) 10 - fs.mkdir(cacheDir) 11 - 12 - let config : Config | undefined 13 - 14 - 15 - fs.readFile(`${configDir}/config.jsonc`, (err: Error, data: string) => { 16 - if (err) { 17 - console.warn(`Got ${err.message} when trying to load config, generating a new one...`) 18 - config = setupConfig(configDir) 19 - } else { 20 - config = JSON.parse(data) 21 - } 22 - }) 23 - 24 - if (config == undefined) { 25 - throw new Error("Couldn't initialize config!") 26 - } 27 - 28 - let imageData = new ImageAnalysisData(config.lightnessStep, config.chromaStep, config.hueStep) 29 - 30 - runPreAnalysis(config.preAnalysisCommands) 31 - 32 - await analyzeImages(config.imageDir, imageData, `${cacheDir}/cache.json`, config.lightnessStep, config.chromaStep, config.hueStep)
+142
analyze/analysis.mts
··· 1 + #!/usr/bin/env zx 2 + /// <reference types="zx/globals" /> 3 + 4 + export interface Config { 5 + imageDir: string 6 + lightnessStep: number 7 + chromaStep: number 8 + hueStep: number 9 + preAnalysisCommands: string[] 10 + } 11 + 12 + export class ImageAnalysisData { 13 + lightnessData: { lightness: number, paths: string[] }[] 14 + chromaData: { chroma: number, paths: string[] }[] 15 + hueData: { hue: number, paths: string[] }[] 16 + 17 + constructor(lightnessStep: number, chromaStep: number, hueStep: number) { 18 + this.lightnessData = [] 19 + this.chromaData = [] 20 + this.hueData = [] 21 + 22 + for (let i = 0; i < 100; i += lightnessStep) { 23 + this.lightnessData.push({ lightness: i, paths: [] }) 24 + } 25 + 26 + for (let i = 0; i < 100; i += chromaStep) { 27 + this.chromaData.push({ chroma: i, paths: [] }) 28 + } 29 + for (let i = 0; i < 360; i += hueStep) { 30 + this.hueData.push({ hue: i, paths: [] }) 31 + } 32 + } 33 + } 34 + 35 + export async function setupConfig(configDir: string) { 36 + const defaultConfig : Config = { 37 + imageDir: `${os.homedir()}/Pictures`, 38 + lightnessStep: 10, 39 + chromaStep: 1, 40 + hueStep: 90, 41 + preAnalysisCommands: [] 42 + } 43 + 44 + await fs.writeFile(`${configDir}/config.json`, JSON.stringify(defaultConfig)) 45 + console.info(`Generated default config at ${configDir}/config.json!`) 46 + return JSON.stringify(defaultConfig) 47 + } 48 + 49 + export async function runPreAnalysis(hooks: string[]) { 50 + for await (const hook of hooks) { 51 + await $`eval ${hook}` // Scary! 52 + } 53 + } 54 + 55 + export async function analyzeImages(imageDir: string, analysisOutput: ImageAnalysisData, outputPath: string, lightnessStep: number, chromaStep: number, hueStep: number) { 56 + const images = await fs.promises.opendir(`${imageDir}`) 57 + 58 + for await (const image of images) { 59 + const path = `${imageDir}/${image.name}` 60 + const stat = await fs.promises.stat(path) 61 + if (stat.isFile()) { 62 + let output = 63 + await $`magick ${path} -colorspace Oklch -kmeans 10 -format "%c" histogram:info:`.then( 64 + (s) => 65 + s.stdout 66 + .split("\n") 67 + .map((substr) => substr.trim()) 68 + .filter((n) => n), 69 + ) as string[] 70 + 71 + if (output.length < 5) { 72 + // do it again but with more violence 73 + console.log( 74 + `-kmeans 10 of ${image.name} didn't give enough colours, trying again with a higher value...`, 75 + ) 76 + output = 77 + await $`magick ${path} -colorspace Oklch -kmeans 40 -format "%c" histogram:info:`.then( 78 + (s) => 79 + s.stdout 80 + .split("\n") // split the output by newlines 81 + .map((substr) => substr.trim()) // trim the whitespace on each line 82 + .filter((n) => n), // and then filter out any blank elements generated 83 + ) 84 + } 85 + 86 + const colours = output.map((entry) => { 87 + const arr = entry.split(" ") 88 + arr.pop() 89 + // [# of pixels, oklab colour, hex code] 90 + return { 91 + pixels: parseInt(arr[0]), 92 + oklab: arr[1].replace(/[()]/g, "").split(","), // (0,0,0) -> Array [0, 0, 0] 93 + hex: arr[2] 94 + } 95 + }) 96 + 97 + colours.sort((a, b) => (b.pixels) - (a.pixels)) 98 + 99 + const dominantColour = colours[0].oklab 100 + 101 + console.log( 102 + `Got oklch(${dominantColour}) as dominant colour of ${image.name}!`, 103 + ) 104 + const lightness = Number(dominantColour[0]) * 100 105 + const chroma = Number(dominantColour[1]) * 100 106 + const hue = Number(dominantColour[2]) 107 + 108 + analysisOutput.lightnessData.forEach((n) => { 109 + const targetLightness = floorToStep(lightness, lightnessStep) 110 + if (n.lightness == targetLightness) { 111 + n.paths.push(path) 112 + return 113 + } 114 + }) 115 + 116 + analysisOutput.chromaData.forEach((n) => { 117 + const targetChroma = floorToStep(chroma, chromaStep) 118 + if (n.chroma == targetChroma) { 119 + n.paths.push(path) 120 + return 121 + } 122 + }) 123 + 124 + analysisOutput.hueData.forEach((n) => { 125 + const targetHue = floorToStep(hue, hueStep) 126 + if (n.hue == targetHue) { 127 + n.paths.push(path) 128 + return 129 + } 130 + }) 131 + 132 + // flush every time we finish analyzing a file, because it just takes so long 133 + fs.writeFile(outputPath, JSON.stringify(analysisOutput, null, 4), (err: Error) => { 134 + if (err) throw new Error(err.message) 135 + }) 136 + } 137 + } 138 + } 139 + 140 + function floorToStep(num: number, step: number) { 141 + return Math.floor(num / step) * step 142 + }
+49
analyze/index.mts
··· 1 + #!/usr/bin/env zx 2 + /// <reference types="zx/globals" /> 3 + 4 + import { analyzeImages, Config, ImageAnalysisData, runPreAnalysis, setupConfig } from "./analysis.mts"; 5 + 6 + const configDir = process.env.XDG_CONFIG_HOME ? `${process.env.XDG_CONFIG_HOME}/rainwall` : `${os.homedir()}/.config/rainwall` 7 + const cacheDir = process.env.XDG_CACHE_HOME ? `${process.env.XDG_CACHE_HOME}/rainwall` : `${os.homedir()}/.cache/rainwall` 8 + 9 + if (!fs.statSync(configDir)){ 10 + fs.mkdirSync(configDir); 11 + } 12 + 13 + if (!fs.statSync(cacheDir)){ 14 + fs.mkdirSync(cacheDir); 15 + } 16 + 17 + let config: Config | undefined 18 + 19 + console.debug(`Trying to load config from ${configDir}/analyze-config.json...`) 20 + 21 + let fsConfig: string | undefined 22 + 23 + try { 24 + fsConfig = await fs.promises.readFile(`${configDir}/analyze-config.json`, { encoding: 'utf8' }) 25 + } catch (err) { 26 + console.warn(`Got ${(err as Error).message} when trying to load config, generating a new one... you may need to stop this process and edit the config file.`) 27 + fsConfig = await setupConfig(configDir) 28 + } finally { 29 + if (fsConfig == undefined) { 30 + console.warn(`Current config file blank, generating a new one... you may need to stop this process and edit the config file.`) 31 + fsConfig = await setupConfig(configDir) 32 + } 33 + config = JSON.parse(fsConfig) 34 + console.info(`Loaded config from ${configDir}/analyze-config.json!`) 35 + } 36 + 37 + if (config == undefined) { 38 + throw new Error("Couldn't initialize config!") 39 + } 40 + 41 + console.debug(`Config: \n${JSON.stringify(config, null, 4)}`) 42 + 43 + fs.rm(`${cacheDir}/analysis.json`, { force: true }) 44 + 45 + const imageData = new ImageAnalysisData(config.lightnessStep, config.chromaStep, config.hueStep) 46 + 47 + await runPreAnalysis(config.preAnalysisCommands) 48 + 49 + await analyzeImages(config.imageDir, imageData, `${cacheDir}/analysis.json`, config.lightnessStep, config.chromaStep, config.hueStep)