🌈️ apply a wallpaper based on the weather outside
0

Configure Feed

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

refactor: restructure to use src directory

Angel Wang (Jun 22, 2026, 8:41 PM -0600) 219bee4c c9903be1

+401 -401
+4 -4
deno.json
··· 7 7 "./apply": "./apply/index.ts" 8 8 }, 9 9 "tasks": { 10 - "compile-analyze": "deno compile --output build/rainwall-analyze -A analyze/index.ts", 11 - "compile-apply": "deno compile --output build/rainwall-apply -A apply/index.ts", 12 - "analyze": "deno -A analyze/index.ts", 13 - "apply": "deno -A apply/index.ts" 10 + "compile-analyze": "deno compile --output build/rainwall-analyze -A ./src/analyze/index.ts", 11 + "compile-apply": "deno compile --output build/rainwall-apply -A ./src/apply/index.ts", 12 + "analyze": "deno -A ./src/analyze/index.ts", 13 + "apply": "deno -A ./src/apply/index.ts" 14 14 }, 15 15 "imports": { 16 16 "@std/assert": "jsr:@std/assert@1",
-98
utils.ts
··· 1 - import * as path from "@std/path" 2 - import "zx/globals" 3 - 4 - function getConfigPath() { 5 - if (Deno.build.os == "windows") { 6 - return path.fromFileUrl(`file:///${os.homedir}/AppData/Local/rainwall`) 7 - } else if (Deno.build.os == "darwin") { 8 - return path.fromFileUrl(`file:///${os.homedir()}/Library/Preferences/rainwall`) 9 - } else { 10 - return process.env.XDG_CONFIG_HOME 11 - ? path.fromFileUrl(`file:///${process.env.XDG_CONFIG_HOME}/rainwall`) 12 - : path.fromFileUrl(`file:///${os.homedir()}/.config/rainwall`) 13 - } 14 - } 15 - 16 - function getCachePath() { 17 - if (Deno.build.os == "windows") { 18 - return path.fromFileUrl(`file:///${os.homedir}/AppData/Local/Temp/rainwall`) 19 - } else if (Deno.build.os == "darwin") { 20 - return path.fromFileUrl(`file:///${os.homedir()}/Library/Caches/rainwall`) 21 - } else { 22 - return process.env.XDG_CACHE_HOME 23 - ? path.fromFileUrl(`file:///${process.env.XDG_CACHE_HOME}/rainwall`) 24 - : path.fromFileUrl(`file:///${os.homedir()}/.cache/rainwall`) 25 - } 26 - } 27 - 28 - export const configDir = getConfigPath() 29 - 30 - export const cacheDir = getCachePath() 31 - 32 - if (!fs.statSync(configDir)) { 33 - fs.mkdirSync(configDir) 34 - } 35 - 36 - if (!fs.statSync(cacheDir)) { 37 - fs.mkdirSync(cacheDir) 38 - } 39 - 40 - export async function loadConfig(pathToConfig: string, defaultConfig: object) { 41 - let config: object | undefined 42 - try { 43 - const stringConfig = await fs.promises.readFile(`${pathToConfig}`, { 44 - encoding: "utf8", 45 - }) 46 - if (stringConfig == undefined) { 47 - console.log( 48 - `Current config blank, generating a new one... you may need to stop this process and edit the config file.`, 49 - ) 50 - config = defaultConfig 51 - writeDefaultConfig(pathToConfig, defaultConfig) 52 - } else { 53 - config = JSON.parse(stringConfig) 54 - } 55 - } catch (err) { 56 - console.warn(chalkWarn( 57 - `Got ${(err as Error).message} when trying to load config, generating a new one... 58 - you may need to stop this process and edit the config file.`, 59 - )) 60 - config = defaultConfig 61 - writeDefaultConfig(pathToConfig, defaultConfig) 62 - } 63 - console.info(`Loaded config from ${pathToConfig}!`) 64 - return config 65 - } 66 - 67 - async function writeDefaultConfig(path: string, data: object) { 68 - await fs.writeFile(`${path}`, JSON.stringify(data, null, 4)) 69 - console.info(`Generated default config at ${path}!`) 70 - } 71 - 72 - export const chalkError = chalk.bold.red 73 - export const chalkWarn = chalk.yellow 74 - export const chalkDebug = chalk.gray 75 - 76 - export function clamp(number: number, min: number, max: number) { 77 - return Math.max(min, Math.min(number, max)) 78 - } 79 - 80 - export function map(number: number, min: number, max: number, newMin: number, newMax: number) { 81 - return ((number - min) / (max - min)) * (newMax - newMin) + newMin 82 - } 83 - 84 - function easeOutQuint(x: number): number { 85 - return 1 - Math.pow(1 - x, 5) 86 - } 87 - 88 - export function mapEaseOutQuint(number: number, min: number, max: number, newMin: number, newMax: number) { 89 - return easeOutQuint((number - min) / (max - min)) * (newMax - newMin) + newMin 90 - } 91 - 92 - function easeOutSqrt(x: number): number { 93 - return Math.sqrt(x) 94 - } 95 - 96 - export function mapEaseOutSqrt(number: number, min: number, max: number, newMin: number, newMax: number) { 97 - return easeOutSqrt((number - min) / (max - min)) * (newMax - newMin) + newMin 98 - }
-83
analyze/analysis.ts
··· 1 - #!/usr/bin/env zx 2 - import "zx/globals" 3 - 4 - export interface AnalysisConfig { 5 - imageDir: string 6 - preAnalysisCommands: string[] 7 - } 8 - 9 - export interface ImageAnalysisData { 10 - files: { path: string; oklch: number[] }[] 11 - } 12 - 13 - export const defaultConfig: AnalysisConfig = { 14 - imageDir: `${os.homedir()}/Pictures`, 15 - preAnalysisCommands: [], 16 - } 17 - 18 - export async function runPreAnalysis(hooks: string[]) { 19 - for await (const hook of hooks) { 20 - await $`eval ${hook}` // Scary! 21 - } 22 - } 23 - 24 - export async function analyzeImages(imageDir: string, analysisOutput: ImageAnalysisData, outputPath: string) { 25 - const images = await fs.promises.opendir(imageDir) 26 - 27 - for await (const image of images) { 28 - const path = `${imageDir}/${image.name}` 29 - const stat = await fs.promises.stat(path) 30 - if (stat.isFile()) { 31 - let output = await $`magick ${path} -colorspace Oklch -kmeans 10 -format "%c" histogram:info:`.then( 32 - (s) => 33 - s.stdout 34 - .split("\n") 35 - .map((substr) => substr.trim()) 36 - .filter((n) => n), 37 - ) as string[] 38 - 39 - if (output.length < 5) { 40 - // do it again but with more violence 41 - console.log( 42 - `-kmeans 10 of ${image.name} didn't give enough colours, trying again with a higher value...`, 43 - ) 44 - output = await $`magick ${path} -colorspace Oklch -kmeans 40 -format "%c" histogram:info:`.then( 45 - (s) => 46 - s.stdout 47 - .split("\n") // split the output by newlines 48 - .map((substr) => substr.trim()) // trim the whitespace on each line 49 - .filter((n) => n), // and then filter out any blank elements generated 50 - ) 51 - } 52 - 53 - const colours = output.map((entry) => { 54 - const arr = entry.split(" ") 55 - arr.pop() 56 - // [# of pixels, oklab colour, hex code] 57 - return { 58 - pixels: parseInt(arr[0]), 59 - oklab: arr[1].replace(/[()]/g, "").split(","), // (0,0,0) -> Array [0, 0, 0] 60 - hex: arr[2], 61 - } 62 - }) 63 - 64 - colours.sort((a, b) => (b.pixels) - (a.pixels)) 65 - 66 - const dominantColour = colours[0].oklab 67 - 68 - console.log( 69 - `Got oklch(${dominantColour}) as dominant colour of ${image.name}!`, 70 - ) 71 - const lightness = Number(dominantColour[0]) 72 - const chroma = Number(dominantColour[1]) 73 - const hue = Number(dominantColour[2]) 74 - 75 - analysisOutput.files.push({ path: path, oklch: [lightness, chroma, hue] }) 76 - 77 - // flush every time we finish analyzing a file, because it just takes so long 78 - fs.writeFile(outputPath, JSON.stringify(analysisOutput, null, 4), (err: Error) => { 79 - if (err) throw new Error(err.message) 80 - }) 81 - } 82 - } 83 - }
-31
analyze/index.ts
··· 1 - #!/usr/bin/env zx 2 - 3 - import { cacheDir, chalkDebug, configDir, loadConfig } from "../utils.ts" 4 - import { AnalysisConfig, analyzeImages, defaultConfig, ImageAnalysisData, runPreAnalysis } from "./analysis.ts" 5 - import "zx/globals" 6 - 7 - const pathToConfig = `${configDir}/analyze-config.json` 8 - 9 - console.debug(chalkDebug(`Trying to load config from ${pathToConfig}...`)) 10 - 11 - const config = await loadConfig(pathToConfig, defaultConfig) as AnalysisConfig 12 - 13 - if (config == undefined) { 14 - throw new Error("Couldn't initialize config!") 15 - } 16 - 17 - console.debug(chalkDebug(`Config: \n${JSON.stringify(config, null, 4)}`)) 18 - 19 - await fs.cp(`${cacheDir}/analysis.json`, `${cacheDir}/analysis.json.prev`, { force: true }, (err: Error) => { 20 - if (err) { 21 - console.log(err) 22 - } 23 - }) 24 - 25 - const imageData = { files: [] } as ImageAnalysisData 26 - 27 - await runPreAnalysis(config.preAnalysisCommands) 28 - 29 - await analyzeImages(config.imageDir, imageData, `${cacheDir}/analysis.json`) 30 - 31 - await fs.rm(`${cacheDir}/analysis.json.prev`, { force: true })
-102
apply/application.ts
··· 1 - import type { ImageAnalysisData } from "../analyze/analysis.ts" 2 - import * as colourDiff from "color-diff" 3 - 4 - export interface ApplicationConfig { 5 - latitude: number 6 - longitude: number 7 - weatherModel: string 8 - lightnessRange: { start: number; end: number } 9 - chromaRange: { start: number; end: number } 10 - applyWallpaperCommand: string 11 - } 12 - 13 - export const defaultConfig: ApplicationConfig = { 14 - latitude: 0, 15 - longitude: 0, 16 - weatherModel: "best_match", 17 - lightnessRange: { 18 - start: 0, 19 - end: 1, 20 - }, 21 - chromaRange: { 22 - start: 0, 23 - end: 1, 24 - }, 25 - applyWallpaperCommand: "hyprctl hyprpaper wallpaper , %s", 26 - } 27 - 28 - export async function getOpenMeteoData( 29 - latitude: number, 30 - longitude: number, 31 - weatherModel: string = "best_match", 32 - ) { 33 - const openMeteoResponse = await fetch( 34 - "https://api.open-meteo.com/v1/forecast", 35 - { 36 - method: "POST", 37 - body: new URLSearchParams({ 38 - latitude: latitude.toString(), 39 - longitude: longitude.toString(), 40 - models: weatherModel, 41 - current: "cloud_cover", 42 - minutely_15: "shortwave_radiation_instant", 43 - forecast_minutely_15: "1", 44 - past_minutely_15: "1" 45 - }), 46 - }, 47 - ) 48 - 49 - if (!openMeteoResponse.ok) { 50 - throw new Error( 51 - `Open-Meteo sent back an error: ${openMeteoResponse.status}`, 52 - ) 53 - } 54 - 55 - const openMeteoData = await openMeteoResponse.json() 56 - 57 - const cloudCover: string = openMeteoData.current.cloud_cover 58 - 59 - let index = 0 60 - openMeteoData.minutely_15.time.forEach((time: string) => { 61 - if (Date.parse(time) > Date.now()) { 62 - // if the data point has a time greater than right now, subtract one to find the one just before the current 63 - // time 64 - index = openMeteoData.minutely_15.time.indexOf(time) - 1 65 - } 66 - }) 67 - const shortwaveRadiation: string = openMeteoData.minutely_15.shortwave_radiation_instant[index] 68 - 69 - return { 70 - cloudCover, 71 - shortwaveRadiation, 72 - } 73 - } 74 - 75 - export function findMatchingImages( 76 - imagesData: ImageAnalysisData, 77 - hueValue: number, 78 - chromaValue: number, 79 - lightnessValue: number, 80 - ) { 81 - // let's convert Oklch to CIELAB first: 82 - const aValue = chromaValue * Math.cos(hueValue) 83 - const bValue = chromaValue * Math.sin(hueValue) 84 - 85 - const matchingImages = [] 86 - 87 - let targetDifference = 0.07 88 - while (matchingImages.length == 0) { 89 - for (const image of imagesData.files) { 90 - const targetAValue = image.oklch[1] * Math.cos(image.oklch[2]) 91 - const targetBValue = image.oklch[1] * Math.sin(image.oklch[2]) 92 - 93 - // then use the CIEDE2000 algorithm to calculate the difference between the colours 94 - const difference = colourDiff.diff({L: lightnessValue, a: aValue, b: bValue}, {L: image.oklch[0], a: targetAValue, b: targetBValue}) 95 - if (difference <= targetDifference) { 96 - matchingImages.push(image) 97 - } 98 - } 99 - targetDifference += 0.01 100 - } 101 - return matchingImages 102 - }
-83
apply/index.ts
··· 1 - #!/usr/bin/env zx 2 - 3 - import type { ImageAnalysisData } from "../analyze/analysis.ts" 4 - import { cacheDir, chalkDebug, configDir, loadConfig, map, mapEaseOutQuint, mapEaseOutSqrt } from "../utils.ts" 5 - import { type ApplicationConfig, defaultConfig, findMatchingImages, getOpenMeteoData } from "./application.ts" 6 - import * as SunCalc from "suncalc" 7 - import "zx/globals" 8 - 9 - const pathToConfig = `${configDir}/apply-config.json` 10 - const pathToCache = `${cacheDir}/analysis.json` 11 - 12 - console.debug(chalkDebug(`Trying to load application config from ${pathToConfig}...`)) 13 - const config = await loadConfig( 14 - pathToConfig, 15 - defaultConfig, 16 - ) as ApplicationConfig 17 - if (config == undefined) { 18 - throw new Error("Couldn't initialize config!") 19 - } 20 - 21 - const openMeteoData = await getOpenMeteoData( 22 - config.latitude, 23 - config.longitude, 24 - config.weatherModel, 25 - ) 26 - const currentSunData = SunCalc.getPosition( 27 - new Date(), 28 - config.latitude, 29 - config.longitude, 30 - ) 31 - const zenithSunData = SunCalc.getPosition( 32 - SunCalc.getTimes(new Date(), config.latitude, config.longitude).solarNoon, 33 - config.latitude, 34 - config.longitude, 35 - ) 36 - const nadirSunData = SunCalc.getPosition( 37 - SunCalc.getTimes(new Date(), config.latitude, config.longitude).nadir, 38 - config.latitude, 39 - config.longitude, 40 - ) 41 - 42 - console.info(`Current cloud cover percentage is ${openMeteoData.cloudCover}%!`) 43 - console.info( 44 - `Current shortwave radiation is ${openMeteoData.shortwaveRadiation} W/m²!`, 45 - ) 46 - console.info(`Current sun altitude is ${currentSunData.altitude}°!`) 47 - 48 - const hueValue = Number(currentSunData.altitude) >= -0.833 49 - ? mapEaseOutQuint(Number(currentSunData.altitude), -0.833, zenithSunData.altitude, 0, 264) 50 - : mapEaseOutQuint(Number(currentSunData.altitude), nadirSunData.altitude, -0.833, 264, 360) 51 - 52 - const chromaValue = map( 53 - 100 - Number(openMeteoData.cloudCover), // gotta invert this one 54 - 0, 55 - 100, 56 - config.chromaRange.start, 57 - config.chromaRange.end, 58 - ) 59 - 60 - const lightnessValue = mapEaseOutSqrt( 61 - Number(openMeteoData.shortwaveRadiation), 62 - 0, 63 - 1000, 64 - config.lightnessRange.start, 65 - config.lightnessRange.end, 66 - ) 67 - 68 - console.info(`Calculated target colour oklch(${lightnessValue} ${chromaValue} ${hueValue})!`) 69 - 70 - console.debug(chalkDebug(`Trying to open cache file ${pathToCache}...`)) 71 - const cacheFile = await fs.promises.readFile(`${pathToCache}`, { 72 - encoding: "utf8", 73 - }) 74 - const imagesData = JSON.parse(cacheFile) as ImageAnalysisData 75 - console.debug(chalkDebug(`Opened cache file ${pathToCache}!`)) 76 - 77 - const targetImages = findMatchingImages(imagesData, hueValue, chromaValue, lightnessValue) 78 - const targetImage = targetImages[Math.floor(Math.random() * targetImages.length)] 79 - console.info(`Found target image ${targetImage.path} with colour oklch(${targetImage.oklch[0]} ${targetImage.oklch[1]} ${targetImage.oklch[2]})!`) 80 - 81 - console.info(`Setting wallpaper...`) 82 - await $`eval ${config.applyWallpaperCommand.replace("%s", targetImage.path)}` 83 - console.info(`Success! Enjoy :)`)
+98
src/utils.ts
··· 1 + import * as path from "@std/path" 2 + import "zx/globals" 3 + 4 + function getConfigPath() { 5 + if (Deno.build.os == "windows") { 6 + return path.fromFileUrl(`file:///${os.homedir}/AppData/Local/rainwall`) 7 + } else if (Deno.build.os == "darwin") { 8 + return path.fromFileUrl(`file:///${os.homedir()}/Library/Preferences/rainwall`) 9 + } else { 10 + return process.env.XDG_CONFIG_HOME 11 + ? path.fromFileUrl(`file:///${process.env.XDG_CONFIG_HOME}/rainwall`) 12 + : path.fromFileUrl(`file:///${os.homedir()}/.config/rainwall`) 13 + } 14 + } 15 + 16 + function getCachePath() { 17 + if (Deno.build.os == "windows") { 18 + return path.fromFileUrl(`file:///${os.homedir}/AppData/Local/Temp/rainwall`) 19 + } else if (Deno.build.os == "darwin") { 20 + return path.fromFileUrl(`file:///${os.homedir()}/Library/Caches/rainwall`) 21 + } else { 22 + return process.env.XDG_CACHE_HOME 23 + ? path.fromFileUrl(`file:///${process.env.XDG_CACHE_HOME}/rainwall`) 24 + : path.fromFileUrl(`file:///${os.homedir()}/.cache/rainwall`) 25 + } 26 + } 27 + 28 + export const configDir = getConfigPath() 29 + 30 + export const cacheDir = getCachePath() 31 + 32 + if (!fs.statSync(configDir)) { 33 + fs.mkdirSync(configDir) 34 + } 35 + 36 + if (!fs.statSync(cacheDir)) { 37 + fs.mkdirSync(cacheDir) 38 + } 39 + 40 + export async function loadConfig(pathToConfig: string, defaultConfig: object) { 41 + let config: object | undefined 42 + try { 43 + const stringConfig = await fs.promises.readFile(`${pathToConfig}`, { 44 + encoding: "utf8", 45 + }) 46 + if (stringConfig == undefined) { 47 + console.log( 48 + `Current config blank, generating a new one... you may need to stop this process and edit the config file.`, 49 + ) 50 + config = defaultConfig 51 + writeDefaultConfig(pathToConfig, defaultConfig) 52 + } else { 53 + config = JSON.parse(stringConfig) 54 + } 55 + } catch (err) { 56 + console.warn(chalkWarn( 57 + `Got ${(err as Error).message} when trying to load config, generating a new one... 58 + you may need to stop this process and edit the config file.`, 59 + )) 60 + config = defaultConfig 61 + writeDefaultConfig(pathToConfig, defaultConfig) 62 + } 63 + console.info(`Loaded config from ${pathToConfig}!`) 64 + return config 65 + } 66 + 67 + async function writeDefaultConfig(path: string, data: object) { 68 + await fs.writeFile(`${path}`, JSON.stringify(data, null, 4)) 69 + console.info(`Generated default config at ${path}!`) 70 + } 71 + 72 + export const chalkError = chalk.bold.red 73 + export const chalkWarn = chalk.yellow 74 + export const chalkDebug = chalk.gray 75 + 76 + export function clamp(number: number, min: number, max: number) { 77 + return Math.max(min, Math.min(number, max)) 78 + } 79 + 80 + export function map(number: number, min: number, max: number, newMin: number, newMax: number) { 81 + return ((number - min) / (max - min)) * (newMax - newMin) + newMin 82 + } 83 + 84 + function easeOutQuint(x: number): number { 85 + return 1 - Math.pow(1 - x, 5) 86 + } 87 + 88 + export function mapEaseOutQuint(number: number, min: number, max: number, newMin: number, newMax: number) { 89 + return easeOutQuint((number - min) / (max - min)) * (newMax - newMin) + newMin 90 + } 91 + 92 + function easeOutSqrt(x: number): number { 93 + return Math.sqrt(x) 94 + } 95 + 96 + export function mapEaseOutSqrt(number: number, min: number, max: number, newMin: number, newMax: number) { 97 + return easeOutSqrt((number - min) / (max - min)) * (newMax - newMin) + newMin 98 + }
+83
src/analyze/analysis.ts
··· 1 + #!/usr/bin/env zx 2 + import "zx/globals" 3 + 4 + export interface AnalysisConfig { 5 + imageDir: string 6 + preAnalysisCommands: string[] 7 + } 8 + 9 + export interface ImageAnalysisData { 10 + files: { path: string; oklch: number[] }[] 11 + } 12 + 13 + export const defaultConfig: AnalysisConfig = { 14 + imageDir: `${os.homedir()}/Pictures`, 15 + preAnalysisCommands: [], 16 + } 17 + 18 + export async function runPreAnalysis(hooks: string[]) { 19 + for await (const hook of hooks) { 20 + await $`eval ${hook}` // Scary! 21 + } 22 + } 23 + 24 + export async function analyzeImages(imageDir: string, analysisOutput: ImageAnalysisData, outputPath: string) { 25 + const images = await fs.promises.opendir(imageDir) 26 + 27 + for await (const image of images) { 28 + const path = `${imageDir}/${image.name}` 29 + const stat = await fs.promises.stat(path) 30 + if (stat.isFile()) { 31 + let output = await $`magick ${path} -colorspace Oklch -kmeans 10 -format "%c" histogram:info:`.then( 32 + (s) => 33 + s.stdout 34 + .split("\n") 35 + .map((substr) => substr.trim()) 36 + .filter((n) => n), 37 + ) as string[] 38 + 39 + if (output.length < 5) { 40 + // do it again but with more violence 41 + console.log( 42 + `-kmeans 10 of ${image.name} didn't give enough colours, trying again with a higher value...`, 43 + ) 44 + output = await $`magick ${path} -colorspace Oklch -kmeans 40 -format "%c" histogram:info:`.then( 45 + (s) => 46 + s.stdout 47 + .split("\n") // split the output by newlines 48 + .map((substr) => substr.trim()) // trim the whitespace on each line 49 + .filter((n) => n), // and then filter out any blank elements generated 50 + ) 51 + } 52 + 53 + const colours = output.map((entry) => { 54 + const arr = entry.split(" ") 55 + arr.pop() 56 + // [# of pixels, oklab colour, hex code] 57 + return { 58 + pixels: parseInt(arr[0]), 59 + oklab: arr[1].replace(/[()]/g, "").split(","), // (0,0,0) -> Array [0, 0, 0] 60 + hex: arr[2], 61 + } 62 + }) 63 + 64 + colours.sort((a, b) => (b.pixels) - (a.pixels)) 65 + 66 + const dominantColour = colours[0].oklab 67 + 68 + console.log( 69 + `Got oklch(${dominantColour}) as dominant colour of ${image.name}!`, 70 + ) 71 + const lightness = Number(dominantColour[0]) 72 + const chroma = Number(dominantColour[1]) 73 + const hue = Number(dominantColour[2]) 74 + 75 + analysisOutput.files.push({ path: path, oklch: [lightness, chroma, hue] }) 76 + 77 + // flush every time we finish analyzing a file, because it just takes so long 78 + fs.writeFile(outputPath, JSON.stringify(analysisOutput, null, 4), (err: Error) => { 79 + if (err) throw new Error(err.message) 80 + }) 81 + } 82 + } 83 + }
+31
src/analyze/index.ts
··· 1 + #!/usr/bin/env zx 2 + 3 + import { cacheDir, chalkDebug, configDir, loadConfig } from "../utils.ts" 4 + import { type AnalysisConfig, analyzeImages, defaultConfig, type ImageAnalysisData, runPreAnalysis } from "./analysis.ts" 5 + import "zx/globals" 6 + 7 + const pathToConfig = `${configDir}/analyze-config.json` 8 + 9 + console.debug(chalkDebug(`Trying to load config from ${pathToConfig}...`)) 10 + 11 + const config = await loadConfig(pathToConfig, defaultConfig) as AnalysisConfig 12 + 13 + if (config == undefined) { 14 + throw new Error("Couldn't initialize config!") 15 + } 16 + 17 + console.debug(chalkDebug(`Config: \n${JSON.stringify(config, null, 4)}`)) 18 + 19 + await fs.cp(`${cacheDir}/analysis.json`, `${cacheDir}/analysis.json.prev`, { force: true }, (err: Error) => { 20 + if (err) { 21 + console.log(err) 22 + } 23 + }) 24 + 25 + const imageData = { files: [] } as ImageAnalysisData 26 + 27 + await runPreAnalysis(config.preAnalysisCommands) 28 + 29 + await analyzeImages(config.imageDir, imageData, `${cacheDir}/analysis.json`) 30 + 31 + await fs.rm(`${cacheDir}/analysis.json.prev`, { force: true })
+102
src/apply/application.ts
··· 1 + import type { ImageAnalysisData } from "../analyze/analysis.ts" 2 + import * as colourDiff from "color-diff" 3 + 4 + export interface ApplicationConfig { 5 + latitude: number 6 + longitude: number 7 + weatherModel: string 8 + lightnessRange: { start: number; end: number } 9 + chromaRange: { start: number; end: number } 10 + applyWallpaperCommand: string 11 + } 12 + 13 + export const defaultConfig: ApplicationConfig = { 14 + latitude: 0, 15 + longitude: 0, 16 + weatherModel: "best_match", 17 + lightnessRange: { 18 + start: 0, 19 + end: 1, 20 + }, 21 + chromaRange: { 22 + start: 0, 23 + end: 1, 24 + }, 25 + applyWallpaperCommand: "hyprctl hyprpaper wallpaper , %s", 26 + } 27 + 28 + export async function getOpenMeteoData( 29 + latitude: number, 30 + longitude: number, 31 + weatherModel: string = "best_match", 32 + ) { 33 + const openMeteoResponse = await fetch( 34 + "https://api.open-meteo.com/v1/forecast", 35 + { 36 + method: "POST", 37 + body: new URLSearchParams({ 38 + latitude: latitude.toString(), 39 + longitude: longitude.toString(), 40 + models: weatherModel, 41 + current: "cloud_cover", 42 + minutely_15: "shortwave_radiation_instant", 43 + forecast_minutely_15: "1", 44 + past_minutely_15: "1" 45 + }), 46 + }, 47 + ) 48 + 49 + if (!openMeteoResponse.ok) { 50 + throw new Error( 51 + `Open-Meteo sent back an error: ${openMeteoResponse.status}`, 52 + ) 53 + } 54 + 55 + const openMeteoData = await openMeteoResponse.json() 56 + 57 + const cloudCover: string = openMeteoData.current.cloud_cover 58 + 59 + let index = 0 60 + openMeteoData.minutely_15.time.forEach((time: string) => { 61 + if (Date.parse(time) > Date.now()) { 62 + // if the data point has a time greater than right now, subtract one to find the one just before the current 63 + // time 64 + index = openMeteoData.minutely_15.time.indexOf(time) - 1 65 + } 66 + }) 67 + const shortwaveRadiation: string = openMeteoData.minutely_15.shortwave_radiation_instant[index] 68 + 69 + return { 70 + cloudCover, 71 + shortwaveRadiation, 72 + } 73 + } 74 + 75 + export function findMatchingImages( 76 + imagesData: ImageAnalysisData, 77 + hueValue: number, 78 + chromaValue: number, 79 + lightnessValue: number, 80 + ) { 81 + // let's convert Oklch to CIELAB first: 82 + const aValue = chromaValue * Math.cos(hueValue) 83 + const bValue = chromaValue * Math.sin(hueValue) 84 + 85 + const matchingImages = [] 86 + 87 + let targetDifference = 0.05 88 + while (matchingImages.length == 0) { 89 + for (const image of imagesData.files) { 90 + const targetAValue = image.oklch[1] * Math.cos(image.oklch[2]) 91 + const targetBValue = image.oklch[1] * Math.sin(image.oklch[2]) 92 + 93 + // then use the CIEDE2000 algorithm to calculate the difference between the colours 94 + const difference = colourDiff.diff({L: lightnessValue, a: aValue, b: bValue}, {L: image.oklch[0], a: targetAValue, b: targetBValue}) 95 + if (difference <= targetDifference) { 96 + matchingImages.push(image) 97 + } 98 + } 99 + targetDifference += 0.01 100 + } 101 + return matchingImages 102 + }
+83
src/apply/index.ts
··· 1 + #!/usr/bin/env zx 2 + 3 + import type { ImageAnalysisData } from "../analyze/analysis.ts" 4 + import { cacheDir, chalkDebug, configDir, loadConfig, map, mapEaseOutQuint, mapEaseOutSqrt } from "../utils.ts" 5 + import { type ApplicationConfig, defaultConfig, findMatchingImages, getOpenMeteoData } from "./application.ts" 6 + import * as SunCalc from "suncalc" 7 + import "zx/globals" 8 + 9 + const pathToConfig = `${configDir}/apply-config.json` 10 + const pathToCache = `${cacheDir}/analysis.json` 11 + 12 + console.debug(chalkDebug(`Trying to load application config from ${pathToConfig}...`)) 13 + const config = await loadConfig( 14 + pathToConfig, 15 + defaultConfig, 16 + ) as ApplicationConfig 17 + if (config == undefined) { 18 + throw new Error("Couldn't initialize config!") 19 + } 20 + 21 + const openMeteoData = await getOpenMeteoData( 22 + config.latitude, 23 + config.longitude, 24 + config.weatherModel, 25 + ) 26 + const currentSunData = SunCalc.getPosition( 27 + new Date(), 28 + config.latitude, 29 + config.longitude, 30 + ) 31 + const zenithSunData = SunCalc.getPosition( 32 + SunCalc.getTimes(new Date(), config.latitude, config.longitude).solarNoon, 33 + config.latitude, 34 + config.longitude, 35 + ) 36 + const nadirSunData = SunCalc.getPosition( 37 + SunCalc.getTimes(new Date(), config.latitude, config.longitude).nadir, 38 + config.latitude, 39 + config.longitude, 40 + ) 41 + 42 + console.info(`Current cloud cover percentage is ${openMeteoData.cloudCover}%!`) 43 + console.info( 44 + `Current shortwave radiation is ${openMeteoData.shortwaveRadiation} W/m²!`, 45 + ) 46 + console.info(`Current sun altitude is ${currentSunData.altitude}°!`) 47 + 48 + const hueValue = Number(currentSunData.altitude) >= -0.833 49 + ? mapEaseOutQuint(Number(currentSunData.altitude), -0.833, zenithSunData.altitude, 0, 264) 50 + : mapEaseOutQuint(Number(currentSunData.altitude), nadirSunData.altitude, -0.833, 264, 360) 51 + 52 + const chromaValue = map( 53 + 100 - Number(openMeteoData.cloudCover), // gotta invert this one 54 + 0, 55 + 100, 56 + config.chromaRange.start, 57 + config.chromaRange.end, 58 + ) 59 + 60 + const lightnessValue = mapEaseOutSqrt( 61 + Number(openMeteoData.shortwaveRadiation), 62 + 0, 63 + 1000, 64 + config.lightnessRange.start, 65 + config.lightnessRange.end, 66 + ) 67 + 68 + console.info(`Calculated target colour oklch(${lightnessValue} ${chromaValue} ${hueValue})!`) 69 + 70 + console.debug(chalkDebug(`Trying to open cache file ${pathToCache}...`)) 71 + const cacheFile = await fs.promises.readFile(`${pathToCache}`, { 72 + encoding: "utf8", 73 + }) 74 + const imagesData = JSON.parse(cacheFile) as ImageAnalysisData 75 + console.debug(chalkDebug(`Opened cache file ${pathToCache}!`)) 76 + 77 + const targetImages = findMatchingImages(imagesData, hueValue, chromaValue, lightnessValue) 78 + const targetImage = targetImages[Math.floor(Math.random() * targetImages.length)] 79 + console.info(`Found target image ${targetImage.path} with colour oklch(${targetImage.oklch[0]} ${targetImage.oklch[1]} ${targetImage.oklch[2]})!`) 80 + 81 + console.info(`Setting wallpaper...`) 82 + await $`eval ${config.applyWallpaperCommand.replace("%s", targetImage.path)}` 83 + console.info(`Success! Enjoy :)`)