🌈️ apply a wallpaper based on the weather outside
0

Configure Feed

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

feat: rewrite analysis output, finish matching algorithm

Angel Wang (Jun 20, 2026, 9:51 PM -0600) 6813f413 c5823dad

+90 -147
+21 -10
utils.mts
··· 1 - 2 1 export const configDir = process.env.XDG_CONFIG_HOME 3 2 ? `${process.env.XDG_CONFIG_HOME}/rainwall` 4 3 : `${os.homedir()}/.config/rainwall` ··· 18 17 let config: object | undefined 19 18 try { 20 19 const stringConfig = await fs.promises.readFile(`${pathToConfig}`, { 21 - encoding: "utf8" 20 + encoding: "utf8", 22 21 }) 23 22 if (stringConfig == undefined) { 24 23 console.warn( 25 - `Current config blank, generating a new one... you may need to stop this process and edit the config file.` 24 + `Current config blank, generating a new one... you may need to stop this process and edit the config file.`, 26 25 ) 27 26 config = defaultConfig 28 27 writeDefaultConfig(pathToConfig, defaultConfig) ··· 33 32 console.warn( 34 33 `Got ${ 35 34 (err as Error).message 36 - } when trying to load config, generating a new one... you may need to stop this process and edit the config file.` 35 + } when trying to load config, generating a new one... you may need to stop this process and edit the config file.`, 37 36 ) 38 37 config = defaultConfig 39 38 writeDefaultConfig(pathToConfig, defaultConfig) ··· 47 46 console.info(`Generated default config at ${path}!`) 48 47 } 49 48 50 - export function floorToStep(num: number, step: number) { 51 - return Math.floor(num / step) * step 52 - } 53 - 54 49 export function clamp(number: number, min: number, max: number) { 55 50 return Math.max(min, Math.min(number, max)) 56 51 } 57 52 58 53 export function map(number: number, min: number, max: number, newMin: number, newMax: number) { 59 - return (((number - min) / (max - min)) * (newMax - newMin)) + newMin 60 - } 54 + return ((number - min) / (max - min)) * (newMax - newMin) + newMin 55 + } 56 + 57 + export function mapEaseInExpo(number: number, min: number, max: number, newMin: number, newMax: number) { 58 + return easeInExpo((number - min) / (max - min)) * (newMax - newMin) + newMin 59 + } 60 + 61 + export function mapEaseOutExpo(number: number, min: number, max: number, newMin: number, newMax: number) { 62 + return easeOutExpo((number - min) / (max - min)) * (newMax - newMin) + newMin 63 + } 64 + 65 + function easeInExpo(x: number): number { 66 + return x === 0 ? 0 : Math.pow(2, 10 * x - 10) 67 + } 68 + 69 + function easeOutExpo(x: number): number { 70 + return x === 1 ? 1 : 1 - Math.pow(2, -10 * x) 71 + }
+20 -72
analyze/analysis.mts
··· 1 1 #!/usr/bin/env zx 2 2 3 - import { floorToStep } from "../utils.mts"; 4 - 5 3 /// <reference types="zx/globals" /> 6 4 7 5 export interface AnalysisConfig { 8 6 imageDir: string 9 - lightnessStep: number 10 - chromaStep: number 11 - hueStep: number 12 7 preAnalysisCommands: string[] 13 8 } 14 9 15 - export class ImageAnalysisData { 16 - lightnessData: { lightness: number, paths: string[] }[] 17 - chromaData: { chroma: number, paths: string[] }[] 18 - hueData: { hue: number, paths: string[] }[] 19 - 20 - constructor(lightnessStep: number, chromaStep: number, hueStep: number) { 21 - this.lightnessData = [] 22 - this.chromaData = [] 23 - this.hueData = [] 24 - 25 - for (let i = 0; i < 100; i += lightnessStep) { 26 - this.lightnessData.push({ lightness: i, paths: [] }) 27 - } 28 - 29 - for (let i = 0; i < 100; i += chromaStep) { 30 - this.chromaData.push({ chroma: i, paths: [] }) 31 - } 32 - for (let i = 0; i < 360; i += hueStep) { 33 - this.hueData.push({ hue: i, paths: [] }) 34 - } 35 - } 10 + export interface ImageAnalysisData { 11 + files: { path: string; oklch: number[] }[] 36 12 } 37 13 38 14 export const defaultConfig: AnalysisConfig = { 39 15 imageDir: `${os.homedir()}/Pictures`, 40 - lightnessStep: 10, 41 - chromaStep: 1, 42 - hueStep: 90, 43 - preAnalysisCommands: [] 16 + preAnalysisCommands: [], 44 17 } 45 18 46 19 export async function runPreAnalysis(hooks: string[]) { ··· 49 22 } 50 23 } 51 24 52 - export async function analyzeImages(imageDir: string, analysisOutput: ImageAnalysisData, outputPath: string, lightnessStep: number, chromaStep: number, hueStep: number) { 25 + export async function analyzeImages(imageDir: string, analysisOutput: ImageAnalysisData, outputPath: string) { 53 26 const images = await fs.promises.opendir(`${imageDir}`) 54 27 55 28 for await (const image of images) { 56 29 const path = `${imageDir}/${image.name}` 57 30 const stat = await fs.promises.stat(path) 58 31 if (stat.isFile()) { 59 - let output = 60 - await $`magick ${path} -colorspace Oklch -kmeans 10 -format "%c" histogram:info:`.then( 61 - (s) => 62 - s.stdout 63 - .split("\n") 64 - .map((substr) => substr.trim()) 65 - .filter((n) => n), 66 - ) as string[] 32 + let output = await $`magick ${path} -colorspace Oklch -kmeans 10 -format "%c" histogram:info:`.then( 33 + (s) => 34 + s.stdout 35 + .split("\n") 36 + .map((substr) => substr.trim()) 37 + .filter((n) => n), 38 + ) as string[] 67 39 68 40 if (output.length < 5) { 69 41 // do it again but with more violence 70 42 console.log( 71 43 `-kmeans 10 of ${image.name} didn't give enough colours, trying again with a higher value...`, 72 44 ) 73 - output = 74 - await $`magick ${path} -colorspace Oklch -kmeans 40 -format "%c" histogram:info:`.then( 75 - (s) => 76 - s.stdout 77 - .split("\n") // split the output by newlines 78 - .map((substr) => substr.trim()) // trim the whitespace on each line 79 - .filter((n) => n), // and then filter out any blank elements generated 80 - ) 45 + output = await $`magick ${path} -colorspace Oklch -kmeans 40 -format "%c" histogram:info:`.then( 46 + (s) => 47 + s.stdout 48 + .split("\n") // split the output by newlines 49 + .map((substr) => substr.trim()) // trim the whitespace on each line 50 + .filter((n) => n), // and then filter out any blank elements generated 51 + ) 81 52 } 82 53 83 54 const colours = output.map((entry) => { ··· 87 58 return { 88 59 pixels: parseInt(arr[0]), 89 60 oklab: arr[1].replace(/[()]/g, "").split(","), // (0,0,0) -> Array [0, 0, 0] 90 - hex: arr[2] 61 + hex: arr[2], 91 62 } 92 63 }) 93 64 ··· 102 73 const chroma = Number(dominantColour[1]) * 100 103 74 const hue = Number(dominantColour[2]) 104 75 105 - analysisOutput.lightnessData.forEach((n) => { 106 - const targetLightness = floorToStep(lightness, lightnessStep) 107 - if (n.lightness == targetLightness) { 108 - n.paths.push(path) 109 - return 110 - } 111 - }) 112 - 113 - analysisOutput.chromaData.forEach((n) => { 114 - const targetChroma = floorToStep(chroma, chromaStep) 115 - if (n.chroma == targetChroma) { 116 - n.paths.push(path) 117 - return 118 - } 119 - }) 120 - 121 - analysisOutput.hueData.forEach((n) => { 122 - const targetHue = floorToStep(hue, hueStep) 123 - if (n.hue == targetHue) { 124 - n.paths.push(path) 125 - return 126 - } 127 - }) 76 + analysisOutput.files.push({ path: path, oklch: [hue, chroma, lightness] }) 128 77 129 78 // flush every time we finish analyzing a file, because it just takes so long 130 79 fs.writeFile(outputPath, JSON.stringify(analysisOutput, null, 4), (err: Error) => { ··· 133 82 } 134 83 } 135 84 } 136 -
+12 -6
analyze/index.mts
··· 1 1 #!/usr/bin/env zx 2 2 /// <reference types="zx/globals" /> 3 3 4 - import { configDir, cacheDir, loadConfig } from "../utils.mts"; 5 - import { analyzeImages, AnalysisConfig, ImageAnalysisData, runPreAnalysis, defaultConfig } from "./analysis.mts"; 4 + import { cacheDir, configDir, loadConfig } from "../utils.mts" 5 + import { AnalysisConfig, analyzeImages, defaultConfig, ImageAnalysisData, runPreAnalysis } from "./analysis.mts" 6 6 7 7 const pathToConfig = `${configDir}/analyze-config.json` 8 8 ··· 11 11 const config = await loadConfig(pathToConfig, defaultConfig) as AnalysisConfig 12 12 13 13 if (config == undefined) { 14 - throw new Error("Couldn't initialize config!") 14 + throw new Error("Couldn't initialize config!") 15 15 } 16 16 17 17 console.debug(`Config: \n${JSON.stringify(config, null, 4)}`) 18 18 19 - fs.rm(`${cacheDir}/analysis.json`, { force: true }) 19 + await fs.cp(`${cacheDir}/analysis.json`, `${cacheDir}/analysis.json.prev`, { force: true }, (err: Error) => { 20 + if (err) { 21 + console.log(err) 22 + } 23 + }) 20 24 21 - const imageData = new ImageAnalysisData(config.lightnessStep, config.chromaStep, config.hueStep) 25 + const imageData = { files: [] } as ImageAnalysisData 22 26 23 27 await runPreAnalysis(config.preAnalysisCommands) 24 28 25 - await analyzeImages(config.imageDir, imageData, `${cacheDir}/analysis.json`, config.lightnessStep, config.chromaStep, config.hueStep) 29 + await analyzeImages(config.imageDir, imageData, `${cacheDir}/analysis.json`) 30 + 31 + await fs.rm(`${cacheDir}/analysis.json.prev`, { force: true })
+12 -19
apply/application.mts
··· 1 1 #!/usr/bin/env zx 2 2 3 3 import { ImageAnalysisData } from "../analyze/analysis.mts" 4 - import _ from "npm:underscore" 5 4 6 5 /// <reference types="zx/globals" /> 7 6 ··· 85 84 chromaValue: number, 86 85 lightnessValue: number, 87 86 ) { 88 - let result = [] 89 - let i = 0 90 - const chromaIndex = imagesData.chromaData.indexOf( 91 - imagesData.chromaData.find((data) => data.chroma === chromaValue)!, 92 - ) 93 - const lightnessIndex = imagesData.lightnessData.indexOf( 94 - imagesData.lightnessData.find((data) => data.lightness === lightnessValue)!, 95 - ) 87 + const targetColour = [hueValue, chromaValue, lightnessValue] 88 + let closestColour = { path: "", oklch: [0, 0, 0] } 96 89 97 - do { 98 - const matches = [ 99 - imagesData.chromaData[chromaIndex + i].paths || imagesData.chromaData[chromaIndex - i].paths, 100 - imagesData.lightnessData[lightnessIndex + i].paths || imagesData.lightnessData[lightnessIndex - i].paths, 101 - ] 102 - console.log(matches) 103 - result = matches.reduce((a, b) => a.filter((c) => b.includes(c))) 104 - 105 - i++ 106 - } while (result.length === 0) 90 + for (const file of imagesData.files) { 91 + if ( 92 + Math.abs(file.oklch[0] - targetColour[0]) <= Math.abs(closestColour.oklch[0] - targetColour[0]) && 93 + Math.abs(file.oklch[1] - targetColour[1]) <= Math.abs(closestColour.oklch[1] - targetColour[1]) && 94 + Math.abs(file.oklch[2] - targetColour[2]) <= Math.abs(closestColour.oklch[2] - targetColour[2]) 95 + ) { 96 + closestColour = file 97 + } 98 + } 99 + return closestColour 107 100 }
+25 -40
apply/index.mts
··· 1 1 #!/usr/bin/env zx 2 2 /// <reference types="zx/globals" /> 3 3 4 - import { AnalysisConfig, defaultConfig as analysisDefaultConfig, ImageAnalysisData } from "../analyze/analysis.mts" 5 - import { cacheDir, configDir, floorToStep, loadConfig, map } from "../utils.mts" 4 + import { ImageAnalysisData } from "../analyze/analysis.mts" 5 + import { cacheDir, configDir, loadConfig, map, mapEaseInExpo, mapEaseOutExpo } from "../utils.mts" 6 6 import { ApplicationConfig, defaultConfig, findMatchingImages, getOpenMeteoData } from "./application.mts" 7 7 import * as SunCalc from "npm:suncalc" 8 8 9 9 const pathToConfig = `${configDir}/apply-config.json` 10 - const pathToAnalysisConfig = `${configDir}/analyze-config.json` 11 10 const pathToCache = `${cacheDir}/analysis.json` 12 11 13 12 console.debug(`Trying to load application config from ${pathToConfig}...`) ··· 18 17 if (config == undefined) { 19 18 throw new Error("Couldn't initialize config!") 20 19 } 21 - 22 - // we also need the analysis config to load the steps 23 - console.debug(`Trying to load analysis config from ${pathToAnalysisConfig}...`) 24 - const analysisConfig = await loadConfig( 25 - pathToAnalysisConfig, 26 - analysisDefaultConfig, 27 - ) as AnalysisConfig 28 - if (analysisConfig == undefined) { 29 - throw new Error("Couldn't initialize config!") 30 - } 31 - 32 - console.debug(`Config: \n${JSON.stringify(config, null, 4)}`) 33 20 34 21 const openMeteoData = await getOpenMeteoData( 35 22 config.latitude, ··· 48 35 ) 49 36 console.info(`Current sun altitude is ${sunCalcData.altitude}°!`) 50 37 51 - // const hueValue = clamp() 52 - const chromaValue = floorToStep( 53 - map( 54 - Number(openMeteoData.cloudCover), 55 - 0, 56 - 100, 57 - config.chromaRange.end, 58 - config.chromaRange.start, 59 - ), 60 - analysisConfig.chromaStep, 38 + const hueValue = Number(sunCalcData.altitude) >= 0 39 + ? mapEaseOutExpo(Number(sunCalcData.altitude), 0, 90, 0, 264) 40 + : mapEaseInExpo(Number(sunCalcData.altitude), 0, -90, 360, 264) 41 + const chromaValue = map( 42 + Number(openMeteoData.cloudCover), 43 + 0, 44 + 100, 45 + config.chromaRange.end, 46 + config.chromaRange.start, 47 + ) 48 + const lightnessValue = map( 49 + Number(openMeteoData.shortwaveRadiation), 50 + 0, 51 + 1000, 52 + config.lightnessRange.start, 53 + config.lightnessRange.end, 61 54 ) 62 55 63 - const lightnessValue = floorToStep( 64 - map( 65 - Number(openMeteoData.shortwaveRadiation), 66 - 0, 67 - 1000, 68 - config.lightnessRange.start, 69 - config.lightnessRange.end, 70 - ), 71 - analysisConfig.lightnessStep, 72 - ) 73 - 74 - console.info(`Got chroma value ${chromaValue}!`) 75 - console.info(`Got lightness value ${lightnessValue}!`) 56 + console.info(`Got hue value ${hueValue}°!`) 57 + console.info(`Got chroma value ${chromaValue}%!`) 58 + console.info(`Got lightness value ${lightnessValue}%!`) 76 59 77 60 console.debug(`Trying to open cache file ${pathToCache}...`) 78 61 const cacheFile = await fs.promises.readFile(`${pathToCache}`, { ··· 81 64 const imagesData = JSON.parse(cacheFile) as ImageAnalysisData 82 65 console.debug(`Opened cache file ${pathToCache}!`) 83 66 67 + const targetFile = findMatchingImages(imagesData, hueValue, chromaValue, lightnessValue) 84 68 85 - 86 - findMatchingImages(imagesData, 0, chromaValue, lightnessValue) 69 + console.info(`Found target image ${targetFile.path}!`) 70 + console.info(`Setting wallpaper...`) 71 + await $`eval ${config.applyWallpaperCommand.replace("%s", targetFile.path)}`