diff --git a/analyze/analysis.mts b/analyze/analysis.mts index 7aac025..8da6702 100755 --- a/analyze/analysis.mts +++ b/analyze/analysis.mts @@ -1,46 +1,19 @@ #!/usr/bin/env zx -import { floorToStep } from "../utils.mts"; - /// export interface AnalysisConfig { 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 < 100; i += lightnessStep) { - this.lightnessData.push({ lightness: i, paths: [] }) - } - - for (let i = 0; i < 100; i += chromaStep) { - this.chromaData.push({ chroma: i, paths: [] }) - } - for (let i = 0; i < 360; i += hueStep) { - this.hueData.push({ hue: i, paths: [] }) - } - } +export interface ImageAnalysisData { + files: { path: string; oklch: number[] }[] } export const defaultConfig: AnalysisConfig = { imageDir: `${os.homedir()}/Pictures`, - lightnessStep: 10, - chromaStep: 1, - hueStep: 90, - preAnalysisCommands: [] + preAnalysisCommands: [], } export async function runPreAnalysis(hooks: string[]) { @@ -49,35 +22,33 @@ export async function runPreAnalysis(hooks: string[]) { } } -export async function analyzeImages(imageDir: string, analysisOutput: ImageAnalysisData, outputPath: string, lightnessStep: number, chromaStep: number, hueStep: number) { +export async function analyzeImages(imageDir: string, analysisOutput: ImageAnalysisData, outputPath: string) { const images = await fs.promises.opendir(`${imageDir}`) 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[] + 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 - ) + 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) => { @@ -87,7 +58,7 @@ export async function analyzeImages(imageDir: string, analysisOutput: ImageAnaly return { pixels: parseInt(arr[0]), oklab: arr[1].replace(/[()]/g, "").split(","), // (0,0,0) -> Array [0, 0, 0] - hex: arr[2] + hex: arr[2], } }) @@ -102,29 +73,7 @@ export async function analyzeImages(imageDir: string, analysisOutput: ImageAnaly const chroma = Number(dominantColour[1]) * 100 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 - } - }) + analysisOutput.files.push({ path: path, oklch: [hue, chroma, lightness] }) // flush every time we finish analyzing a file, because it just takes so long fs.writeFile(outputPath, JSON.stringify(analysisOutput, null, 4), (err: Error) => { @@ -133,4 +82,3 @@ export async function analyzeImages(imageDir: string, analysisOutput: ImageAnaly } } } - diff --git a/analyze/index.mts b/analyze/index.mts index 90daead..5ad18d5 100755 --- a/analyze/index.mts +++ b/analyze/index.mts @@ -1,8 +1,8 @@ #!/usr/bin/env zx /// -import { configDir, cacheDir, loadConfig } from "../utils.mts"; -import { analyzeImages, AnalysisConfig, ImageAnalysisData, runPreAnalysis, defaultConfig } from "./analysis.mts"; +import { cacheDir, configDir, loadConfig } from "../utils.mts" +import { AnalysisConfig, analyzeImages, defaultConfig, ImageAnalysisData, runPreAnalysis } from "./analysis.mts" const pathToConfig = `${configDir}/analyze-config.json` @@ -11,15 +11,21 @@ console.debug(`Trying to load config from ${pathToConfig}...`) const config = await loadConfig(pathToConfig, defaultConfig) as AnalysisConfig if (config == undefined) { - throw new Error("Couldn't initialize config!") + throw new Error("Couldn't initialize config!") } console.debug(`Config: \n${JSON.stringify(config, null, 4)}`) -fs.rm(`${cacheDir}/analysis.json`, { force: true }) +await fs.cp(`${cacheDir}/analysis.json`, `${cacheDir}/analysis.json.prev`, { force: true }, (err: Error) => { + if (err) { + console.log(err) + } +}) -const imageData = new ImageAnalysisData(config.lightnessStep, config.chromaStep, config.hueStep) +const imageData = { files: [] } as ImageAnalysisData await runPreAnalysis(config.preAnalysisCommands) -await analyzeImages(config.imageDir, imageData, `${cacheDir}/analysis.json`, config.lightnessStep, config.chromaStep, config.hueStep) \ No newline at end of file +await analyzeImages(config.imageDir, imageData, `${cacheDir}/analysis.json`) + +await fs.rm(`${cacheDir}/analysis.json.prev`, { force: true }) diff --git a/apply/application.mts b/apply/application.mts index c166a06..86e182b 100644 --- a/apply/application.mts +++ b/apply/application.mts @@ -1,7 +1,6 @@ #!/usr/bin/env zx import { ImageAnalysisData } from "../analyze/analysis.mts" -import _ from "npm:underscore" /// @@ -85,23 +84,17 @@ export function findMatchingImages( chromaValue: number, lightnessValue: number, ) { - let result = [] - let i = 0 - const chromaIndex = imagesData.chromaData.indexOf( - imagesData.chromaData.find((data) => data.chroma === chromaValue)!, - ) - const lightnessIndex = imagesData.lightnessData.indexOf( - imagesData.lightnessData.find((data) => data.lightness === lightnessValue)!, - ) - - do { - const matches = [ - imagesData.chromaData[chromaIndex + i].paths || imagesData.chromaData[chromaIndex - i].paths, - imagesData.lightnessData[lightnessIndex + i].paths || imagesData.lightnessData[lightnessIndex - i].paths, - ] - console.log(matches) - result = matches.reduce((a, b) => a.filter((c) => b.includes(c))) + const targetColour = [hueValue, chromaValue, lightnessValue] + let closestColour = { path: "", oklch: [0, 0, 0] } - i++ - } while (result.length === 0) + for (const file of imagesData.files) { + if ( + Math.abs(file.oklch[0] - targetColour[0]) <= Math.abs(closestColour.oklch[0] - targetColour[0]) && + Math.abs(file.oklch[1] - targetColour[1]) <= Math.abs(closestColour.oklch[1] - targetColour[1]) && + Math.abs(file.oklch[2] - targetColour[2]) <= Math.abs(closestColour.oklch[2] - targetColour[2]) + ) { + closestColour = file + } + } + return closestColour } diff --git a/apply/index.mts b/apply/index.mts index 2c8cd2b..6fc18f9 100644 --- a/apply/index.mts +++ b/apply/index.mts @@ -1,13 +1,12 @@ #!/usr/bin/env zx /// -import { AnalysisConfig, defaultConfig as analysisDefaultConfig, ImageAnalysisData } from "../analyze/analysis.mts" -import { cacheDir, configDir, floorToStep, loadConfig, map } from "../utils.mts" +import { ImageAnalysisData } from "../analyze/analysis.mts" +import { cacheDir, configDir, loadConfig, map, mapEaseInExpo, mapEaseOutExpo } from "../utils.mts" import { ApplicationConfig, defaultConfig, findMatchingImages, getOpenMeteoData } from "./application.mts" import * as SunCalc from "npm:suncalc" const pathToConfig = `${configDir}/apply-config.json` -const pathToAnalysisConfig = `${configDir}/analyze-config.json` const pathToCache = `${cacheDir}/analysis.json` console.debug(`Trying to load application config from ${pathToConfig}...`) @@ -19,18 +18,6 @@ if (config == undefined) { throw new Error("Couldn't initialize config!") } -// we also need the analysis config to load the steps -console.debug(`Trying to load analysis config from ${pathToAnalysisConfig}...`) -const analysisConfig = await loadConfig( - pathToAnalysisConfig, - analysisDefaultConfig, -) as AnalysisConfig -if (analysisConfig == undefined) { - throw new Error("Couldn't initialize config!") -} - -console.debug(`Config: \n${JSON.stringify(config, null, 4)}`) - const openMeteoData = await getOpenMeteoData( config.latitude, config.longitude, @@ -48,31 +35,27 @@ console.info( ) console.info(`Current sun altitude is ${sunCalcData.altitude}°!`) -// const hueValue = clamp() -const chromaValue = floorToStep( - map( - Number(openMeteoData.cloudCover), - 0, - 100, - config.chromaRange.end, - config.chromaRange.start, - ), - analysisConfig.chromaStep, +const hueValue = Number(sunCalcData.altitude) >= 0 + ? mapEaseOutExpo(Number(sunCalcData.altitude), 0, 90, 0, 264) + : mapEaseInExpo(Number(sunCalcData.altitude), 0, -90, 360, 264) +const chromaValue = map( + Number(openMeteoData.cloudCover), + 0, + 100, + config.chromaRange.end, + config.chromaRange.start, ) - -const lightnessValue = floorToStep( - map( - Number(openMeteoData.shortwaveRadiation), - 0, - 1000, - config.lightnessRange.start, - config.lightnessRange.end, - ), - analysisConfig.lightnessStep, +const lightnessValue = map( + Number(openMeteoData.shortwaveRadiation), + 0, + 1000, + config.lightnessRange.start, + config.lightnessRange.end, ) -console.info(`Got chroma value ${chromaValue}!`) -console.info(`Got lightness value ${lightnessValue}!`) +console.info(`Got hue value ${hueValue}°!`) +console.info(`Got chroma value ${chromaValue}%!`) +console.info(`Got lightness value ${lightnessValue}%!`) console.debug(`Trying to open cache file ${pathToCache}...`) const cacheFile = await fs.promises.readFile(`${pathToCache}`, { @@ -81,6 +64,8 @@ const cacheFile = await fs.promises.readFile(`${pathToCache}`, { const imagesData = JSON.parse(cacheFile) as ImageAnalysisData console.debug(`Opened cache file ${pathToCache}!`) +const targetFile = findMatchingImages(imagesData, hueValue, chromaValue, lightnessValue) - -findMatchingImages(imagesData, 0, chromaValue, lightnessValue) \ No newline at end of file +console.info(`Found target image ${targetFile.path}!`) +console.info(`Setting wallpaper...`) +await $`eval ${config.applyWallpaperCommand.replace("%s", targetFile.path)}` diff --git a/utils.mts b/utils.mts index ac02769..ef122df 100644 --- a/utils.mts +++ b/utils.mts @@ -1,4 +1,3 @@ - export const configDir = process.env.XDG_CONFIG_HOME ? `${process.env.XDG_CONFIG_HOME}/rainwall` : `${os.homedir()}/.config/rainwall` @@ -18,11 +17,11 @@ export async function loadConfig(pathToConfig: string, defaultConfig: object) { let config: object | undefined try { const stringConfig = await fs.promises.readFile(`${pathToConfig}`, { - encoding: "utf8" + encoding: "utf8", }) if (stringConfig == undefined) { console.warn( - `Current config blank, generating a new one... you may need to stop this process and edit the config file.` + `Current config blank, generating a new one... you may need to stop this process and edit the config file.`, ) config = defaultConfig writeDefaultConfig(pathToConfig, defaultConfig) @@ -33,7 +32,7 @@ export async function loadConfig(pathToConfig: string, defaultConfig: object) { 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.` + } when trying to load config, generating a new one... you may need to stop this process and edit the config file.`, ) config = defaultConfig writeDefaultConfig(pathToConfig, defaultConfig) @@ -47,14 +46,26 @@ async function writeDefaultConfig(path: string, data: object) { console.info(`Generated default config at ${path}!`) } -export function floorToStep(num: number, step: number) { - return Math.floor(num / step) * step -} - export function clamp(number: number, min: number, max: number) { return Math.max(min, Math.min(number, max)) } export function map(number: number, min: number, max: number, newMin: number, newMax: number) { - return (((number - min) / (max - min)) * (newMax - newMin)) + newMin -} \ No newline at end of file + return ((number - min) / (max - min)) * (newMax - newMin) + newMin +} + +export function mapEaseInExpo(number: number, min: number, max: number, newMin: number, newMax: number) { + return easeInExpo((number - min) / (max - min)) * (newMax - newMin) + newMin +} + +export function mapEaseOutExpo(number: number, min: number, max: number, newMin: number, newMax: number) { + return easeOutExpo((number - min) / (max - min)) * (newMax - newMin) + newMin +} + +function easeInExpo(x: number): number { + return x === 0 ? 0 : Math.pow(2, 10 * x - 10) +} + +function easeOutExpo(x: number): number { + return x === 1 ? 1 : 1 - Math.pow(2, -10 * x) +}