beatprints.js
Version:
A Node.js version of the original Python BeatPrints project (https://github.com/TrueMyst/BeatPrints/) by TrueMyst. Create eye-catching, Pinterest-style music posters effortlessly. BeatPrints integrates with Spotify and LRClib API to help you design custom
157 lines (156 loc) • 6.18 kB
JavaScript
import { join } from 'node:path';
import axios from 'axios';
import sharp from 'sharp';
import Vibrant from 'sharp-vibrant';
import { Size, Position, ThemesSelector } from './constants.js';
import { pickRandom } from './utils.js';
import { readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { Worker } from 'node:worker_threads';
import { fileURLToPath } from 'node:url';
const paletteCache = new Map();
export async function getPalette(image) {
const hash = createHash('sha1').update(image).digest('hex');
if (paletteCache.has(hash))
return paletteCache.get(hash);
const palette = await Vibrant.from(image)
.maxColorCount(6)
.getPalette();
const colors = Object.values(palette.palette)
.filter(swatch => swatch !== null)
.map(swatch => swatch?.rgb.map(c => Math.round(c)));
paletteCache.set(hash, colors);
return colors;
}
/**
* Draws a color palette on the given image.
* @param {CanvasRenderingContext2D} ctx The canvas rendering context.
* @param {Buffer} image The image from which the color palette will be drawn.
* @param {boolean} accent If true, an accent color is added at the bottom. Defaults to false.
*/
export async function drawPalette(ctx, image, accent = false) {
const palette = await getPalette(image);
for (let i = 0; i < palette.length; i++) {
const [r, g, b] = palette[i];
const [x, y] = Position.PALETTE;
const [start, end] = [Size.PL_WIDTH * i, Size.PL_WIDTH * (i + 1)];
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
ctx.fillRect(x + start, y, end - start, Size.PL_HEIGHT);
}
if (accent) {
const color = pickRandom(palette);
ctx.fillStyle = `rgb(${color.join(',')})`;
ctx.fillRect(...Position.ACCENT);
}
}
/**
* Crops the image buffer to a square aspect ratio.
* @param {Buffer} buffer The image from which the crop will be applied.
* @returns {Promise<Buffer>} A new buffer containing the cropped square image.
*/
export async function crop(buffer) {
const image = sharp(buffer);
const metadata = await image.metadata();
const { width, height } = metadata;
const minSize = Math.min(width, height);
const left = Math.floor((width - minSize) / 2);
const top = Math.floor((height - minSize) / 2);
return image.extract({ left, top, width: minSize, height: minSize }).toBuffer();
}
/**
* Adjusts the brightness and contrast of an image buffer.
*
* - Brightness is reduced by 10% (i.e., 90% of original)
* - Contrast is reduced by 20% (i.e., 80% of original)
*
* @param {Buffer} image Buffer or Sharp instance of the image
* @returns {Promise<Buffer>} The processed image buffer
*/
export async function magicify(image) {
return sharp(image)
.modulate({ brightness: 0.9 })
.linear(0.8, 0)
.toBuffer();
}
/**
* Generates a Spotify scannable code for a track or album.
* @param {string} id The Spotify track or album ID.
* @param {ThemesSelector.Options} theme The theme for the scannable code. Defaults to 'Light'.
* @param {'track' | 'album'} item Specifies the type of the scannable code. Defaults to 'track'.
* @returns {Promise<Buffer>} A buffer containing the resized scannable code image.
*/
export async function scannable(id, theme = 'Light', item = 'track') {
const variant = [...ThemesSelector.THEMES[theme], 255];
const scan_url = `https://scannables.scdn.co/uri/plain/png/101010/white/1280/spotify:${item}:${id}`;
const data = (await axios.get(scan_url, { responseType: 'arraybuffer' })).data;
const { data: raw, info } = await sharp(data)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const newRaw = await replaceWhite(raw, variant);
const buffer = sharp(newRaw, {
raw: {
width: info.width,
height: info.height,
channels: 4
}
})
.resize(Size.SCANCODE[0], Size.SCANCODE[1], { kernel: 'cubic' })
.png()
.toBuffer();
return buffer;
}
/**
* Fetches and processes an image from a URL or Buffer image.
* @param {string} url The URL of the image.
* @param {string | Buffer} source The local path of the image. If provided, the image will be loaded from this path; otherwise, it will be fetched from the URL.
* @returns {Promise<Buffer>} A buffer containing the processed image.
*/
export async function cover(url, source) {
let buffer;
if (source) {
if (typeof source === 'string') {
if (source.startsWith('http')) {
const res = await axios.get(source, { responseType: 'arraybuffer' });
buffer = Buffer.from(res.data);
}
else {
buffer = await readFile(source);
}
}
else {
buffer = source;
}
}
else {
const res = await axios.get(url, { responseType: 'arraybuffer' });
buffer = Buffer.from(res.data);
}
const cropped = await crop(buffer);
return magicify(cropped);
}
/**
* Returns theme-related properties based on the selected theme.
* @param {ThemesSelector.Options} theme The selected theme. Defaults to 'Light'.
* @returns {[RGB, string]} A tuple containing the thee color and the template path.
*/
export function getTheme(theme = 'Light') {
const variant = ThemesSelector.THEMES[theme];
const templateUrl = new URL(`./assets/templates/${theme.toLowerCase()}.png`, import.meta.url).pathname;
return [variant, templateUrl];
}
async function replaceWhite(raw, variant) {
return new Promise((resolve, reject) => {
const worker = new Worker(fileURLToPath(new URL('./threads/replaceWhite.js', import.meta.url)));
worker.on('message', (modified) => {
resolve(Buffer.from(modified));
});
worker.on('error', reject);
worker.on('exit', code => {
if (code !== 0)
reject(new Error(`Worker stopped with exit code ${code}`));
});
const array = new Uint8ClampedArray(raw);
worker.postMessage({ buffer: array.buffer, variant }, [array.buffer]);
});
}