companion-module-utils
Version:
Utility tools for use in Bitfocus Companion modules
517 lines (449 loc) • 15.6 kB
text/typescript
import { PNG } from 'pngjs'
import icons from './icons.json'
import { Cache, combineRGB } from './util'
export interface BarColor {
size: number
color: number
background: number
backgroundOpacity: number
}
type IconType =
| 'directionDown'
| 'directionDownLeft'
| 'directionDownRight'
| 'directionLeft'
| 'directionRight'
| 'directionUp'
| 'directionUpLeft'
| 'directionUpRight'
| 'forward'
| 'headset1'
| 'headset2'
| 'headset3'
| 'headset4'
| 'mic1'
| 'mic2'
| 'mic3'
| 'mic4'
| 'mic5'
| 'pause'
| 'play'
| 'playPause'
| 'power'
| 'record'
| 'recordRed'
| 'rewind'
| 'stop'
| 'custom'
export interface Icon {
type: IconType
base64: string
argb: number[]
width: number
height: number
}
export interface OptionsBar {
width: number
height: number
colors: BarColor[]
barLength: number
barWidth: number
value: number
type: 'vertical' | 'horizontal'
offsetX?: number
offsetY?: number
opacity?: number
reverse?: boolean
}
export interface OptionsBorder {
width: number
height: number
color: number
size: number
opacity?: number
type?: 'border' | 'top' | 'bottom' | 'left' | 'right'
}
export interface OptionsCircle {
radius: number
color: number
opacity?: number
}
export interface OptionsCorner {
width: number
height: number
color: number
size?: number
location: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'
opacity?: number
}
export interface OptionsIcon {
width: number
height: number
type: IconType | 'Custom'
custom?: Uint8Array
customWidth?: number
customHeight?: number
offsetX?: number
offsetY?: number
}
export interface OptionsRect {
width: number
height: number
color: number
rectWidth: number
rectHeight: number
strokeWidth: number
fillColor?: number
fillOpacity?: number
offsetX?: number
offsetY?: number
opacity?: number
}
export interface OptionParseBase64 {
alpha?: boolean
}
export interface OptionToPNG64 {
image: Buffer | Uint8Array
width: number
height: number
}
const cache = new Cache()
/**
* Generates an image buffer of a bar and calculates the position of the specified value along that bar
*
* @param options - Required and optional params for the bar
* @returns Image buffer containing a bar as per options
*/
export const bar = (options: OptionsBar): Uint8Array => {
let value = options.value
if (value < 0) value = 0
if (value > 100) value = 100
const offsetX = options.offsetX || 0
const offsetY = options.offsetY || 0
const buffer = Buffer.alloc(options.width * options.height * 4)
const increment = options.barLength / 100
const position = Math.floor(increment * options.value)
// Create a key for the image buffer given the options provided, and return a cached buffer if it exists
const cacheKey = `bar-${options.width}-${options.height}-${JSON.stringify(options.colors)}-${
options.opacity || 0
}-${offsetX}-${offsetY}-${options.barLength}-${options.barWidth}-${options.type}-${position}-${
options.reverse || 'false'
}`
if (cache.get(cacheKey)) return cache.get(cacheKey) as Uint8Array
// Calculate the bar image
const bar = []
for (let i = 0; i < options.barLength; i++) {
const active = i < position
let barColor = options.colors[0].color
let barOpacity = options.opacity ?? 255
let colorTotal = 0
options.colors.forEach((color) => {
if (i / increment >= colorTotal) {
barColor = active ? color.color : color.background
colorTotal += color.size
barOpacity = active ? options.opacity ?? 255 : color.backgroundOpacity
}
})
for (let j = 0; j < options.barWidth; j++) {
bar.push(barOpacity * Math.pow(2, 24) + barColor)
}
}
// Reverse the direction the bar is displayed
if (options.reverse) {
bar.reverse()
}
// Render the bar onto the buffer
if (options.type === 'vertical') {
bar.reverse()
bar.forEach((pixel, i) => {
const y = Math.floor(i / options.barWidth) + offsetY
const x = (i % options.barWidth) + offsetX
const index = y * options.width + x
buffer.writeUint32BE(pixel, index * 4)
})
} else {
bar.forEach((pixel, i) => {
const y = (i % options.barWidth) + offsetY
const x = Math.floor(i / options.barWidth) + offsetX
const index = y * options.width + x
buffer.writeUint32BE(pixel, index * 4)
})
}
cache.set(cacheKey, buffer)
return buffer
}
/**
* Generates an image buffer of border
*
* @param options - Required and optional params for the indicator
* @returns Image buffer containing a border indicator as per options
*/
export const border = (options: OptionsBorder): Uint8Array => {
const type = options.type || 'border'
// Create a key for the image buffer given the options provided, and return a cached buffer if it exists
const cacheKey = `border-${options.width}-${options.height}-${options.color}-${options.opacity || 0}-${
options.size
}-${type}`
if (cache.get(cacheKey)) return cache.get(cacheKey) as Uint8Array
const buffer = Buffer.alloc(options.width * options.height * 4)
const opacity = options.opacity ?? 255
const color = opacity * Math.pow(2, 24) + options.color
const drawPixel = (x: number, y: number): void => {
const index = y * options.width + x
buffer.writeUint32BE(color, index * 4)
}
for (let y = 0; y < options.height; y++) {
for (let x = 0; x < options.width; x++) {
if (type === 'border') {
if (
x < options.size ||
x > options.width - options.size ||
y < options.size ||
y > options.height - options.size
)
drawPixel(x, y)
} else if (type === 'top') {
if (y < options.size) drawPixel(x, y)
} else if (type === 'bottom') {
if (y > options.height - options.size) drawPixel(x, y)
} else if (type === 'left') {
if (x < options.size) drawPixel(x, y)
} else if (type === 'right') {
if (x > options.width - options.size) drawPixel(x, y)
}
}
}
cache.set(cacheKey, buffer)
return buffer
}
/**
* Generates an image buffer of a circle at a specified radius. Width and Height are 2 x radius
*
* @param options - Required and optional params for the indicator
* @returns Image buffer containing a circle as per options
*/
export const circle = (options: OptionsCircle): Uint8Array => {
// Create a key for the image buffer given the options provided, and return a cached buffer if it exists
const cacheKey = `circle-${JSON.stringify(options)}`
if (cache.get(cacheKey)) return cache.get(cacheKey) as Uint8Array
const diameter = options.radius * 2
const buffer = Buffer.alloc(diameter * diameter * 4)
const opacity = options.opacity ?? 255
const color = opacity * Math.pow(2, 24) + options.color
for (let y = 0; y < diameter; y++) {
for (let x = 0; x < diameter; x++) {
const withinRadius = Math.sqrt(Math.pow(x - diameter / 2, 2) + Math.pow(y - diameter / 2, 2)) <= options.radius
if (withinRadius) {
const index = y * diameter + x
buffer.writeUint32BE(color, index * 4)
}
}
}
cache.set(cacheKey, buffer)
return buffer
}
/**
* Generates an image buffer of a corner indicator
*
* @param options - Required and optional params for the indicator
* @returns Image buffer containing a corner indicator as per options
*/
export const corner = (options: OptionsCorner): Uint8Array => {
// Create a key for the image buffer given the options provided, and return a cached buffer if it exists
const cacheKey = `corner-${options.width}-${options.height}-${options.color}-${options.opacity || 0}-${
options.size
}-${options.location}`
if (cache.get(cacheKey)) return cache.get(cacheKey) as Uint8Array
const buffer = Buffer.alloc(options.width * options.height * 4)
const opacity = options.opacity ?? 255
const color = opacity * Math.pow(2, 24) + options.color
const hAlign = options.location.includes('Right') ? 'right' : 'left'
const vAlign = options.location.includes('top') ? 'top' : 'bottom'
const sizeHeight = options.size || options.height * 0.33
const sizeWidth = options.size || options.width * 0.33
for (let y = 0; y < sizeHeight; y++) {
const trueY = vAlign === 'bottom' ? options.height - 1 - y : y
for (let x = 0; x < sizeWidth - y; x++) {
const trueX = hAlign === 'right' ? options.width - 1 - x : x
const index = trueY * options.width + trueX
buffer.writeUint32BE(color, index * 4)
}
}
cache.set(cacheKey, buffer)
return buffer
}
/**
* Generates an image buffer with a specified icon
*
* @param options - Required and options params for the icon
* @returns Image buffer containing an icon as per options
*/
export const icon = (options: OptionsIcon): Uint8Array => {
// Create a key for the image buffer given the options provided, and return a cached buffer if it exists
const cacheKey = `icon-${JSON.stringify(options)}`
if (cache.get(cacheKey)) return cache.get(cacheKey) as Uint8Array
const buffer = Buffer.alloc(options.width * options.height * 4)
const offsetX = options.offsetX || 0
const offsetY = options.offsetY || 0
let iconWidth = 0
let iconHeight = 0
let iconData: number[]
if (options.type === 'custom') {
if (!options.custom || !options.customWidth || !options.customHeight)
throw new Error('custom, customWidth and customHeight MUST be provided when using a custom icon')
iconData = [...options.custom]
iconWidth = options.customWidth
iconHeight = options.customHeight
} else {
const icon = icons.find((icon) => icon.type === options.type)
if (!icon) throw new Error(`Can't find matching icon data of type ${options.type}`)
iconData = [...icon.argb]
iconHeight = icon.height
iconWidth = icon.width
}
for (let y = offsetY; y < offsetY + iconHeight; y++) {
for (let x = offsetX; x < offsetX + iconWidth; x++) {
const index = y * options.width + x
if (options.type === 'custom') {
const color =
(iconData.shift() || 0) * Math.pow(2, 24) +
combineRGB(iconData.shift() || 0, iconData.shift() || 0, iconData.shift() || 0)
buffer.writeUint32BE(color, index * 4)
} else {
const color = iconData.shift() || 0
buffer.writeUint32BE(color, index * 4)
}
}
}
cache.set(cacheKey, buffer)
return buffer
}
/**
* Generates an image buffer of a rectangle
*
* @param options - Required and optional params for the indicator
* @returns Image buffer containing a rectangle as per options
*/
export const rect = (options: OptionsRect): Uint8Array => {
// Create a key for the image buffer given the options provided, and return a cached buffer if it exists
const cacheKey = `rect-${JSON.stringify(options)}`
if (cache.get(cacheKey)) return cache.get(cacheKey) as Uint8Array
const buffer = Buffer.alloc(options.width * options.height * 4)
const opacity = options.opacity ?? 255
const color = opacity * Math.pow(2, 24) + options.color
const fillOpacity = options.fillOpacity ?? 255
const fillColor = fillOpacity * Math.pow(2, 24) + (options.fillColor ?? options.color)
const offsetX = options.offsetX || 0
const offsetY = options.offsetY || 0
for (let y = 0; y < options.height; y++) {
for (let x = 0; x < options.width; x++) {
if (y >= offsetY && y < offsetY + options.rectHeight && x >= offsetX && x < offsetX + options.rectWidth) {
const index = y * options.width + x
if (
y >= offsetY + options.strokeWidth &&
y < offsetY + options.rectHeight - options.strokeWidth &&
x >= offsetX + options.strokeWidth &&
x < offsetX + options.rectWidth - options.strokeWidth
) {
buffer.writeUint32BE(fillColor, index * 4)
} else {
buffer.writeUint32BE(color, index * 4)
}
}
}
}
cache.set(cacheKey, buffer)
return buffer
}
/**
* Stacks an array of same-size image buffers into a single buffer
*
* @param buffers - Array of image buffers
* @returns A single image buffer
*/
export const stackImage = (buffers: Uint8Array[]): Uint8Array => {
const stack: Uint8Array = Buffer.alloc(buffers[0].length)
buffers[0].forEach((value, index) => {
stack[index] = value
})
for (let i = 1; i < buffers.length; i++) {
if (stack.length !== buffers[i].length) throw new Error('Images to be stacked must be the same size')
for (let j = 0; j < buffers[i].length; j += 4) {
const base: number[] = [stack[j] / 255, stack[j + 1], stack[j + 2], stack[j + 3]]
const added: number[] = [buffers[i][j] / 255, buffers[i][j + 1], buffers[i][j + 2], buffers[i][j + 3]]
const mix: number[] = []
if (added[0] === 0 && added[1] === 0 && added[2] === 0 && added[3] === 0) {
added[0] = base[0]
added[1] = base[1]
added[2] = base[2]
added[3] = base[3]
} else if (base[0] === 0 && base[1] === 0 && base[2] === 0 && base[3] === 0) {
base[0] = added[0]
base[1] = added[1]
base[2] = added[2]
base[3] = added[3]
}
mix[0] = 1 - (1 - added[0]) * (1 - base[0])
mix[1] = Math.round(base[1] * base[0] * (1 - added[0]) + added[1] * added[0])
mix[2] = Math.round(base[2] * base[0] * (1 - added[0]) + added[2] * added[0])
mix[3] = Math.round(base[3] * base[0] * (1 - added[0]) + added[3] * added[0])
stack[j] = mix[0] * 255
stack[j + 1] = mix[1]
stack[j + 2] = mix[2]
stack[j + 3] = mix[3]
}
}
return stack
}
/**
* Converts a base64 encoded PNG such as that used by png64 feedback into an imagebuffer that can be used with other functions
*
* @param png64 - base64 encoded PNG string
* @param options - Required and optional params
* @returns An image buffer
*/
export const parseBase64 = (png64: string, options?: OptionParseBase64): Promise<Uint8Array> => {
return new Promise((resolve, reject) => {
const cacheKey = `parseBase64-${png64}`
if (cache.get(cacheKey)) return resolve(cache.get(cacheKey) as Uint8Array)
new PNG({ inputHasAlpha: options?.alpha ?? true }).parse(Buffer.from(png64, 'base64'), (err, data) => {
if (err) return reject(err)
const buffer = Buffer.alloc(data.width * data.height * 4)
for (let y = 0; y < data.height; y++) {
for (let x = 0; x < data.width; x++) {
const i = (data.width * y + x) << 2
const color =
data.data[i + 3] * Math.pow(2, 24) + combineRGB(data.data[i], data.data[i + 1], data.data[i + 2])
const index = y * data.width + x
buffer.writeUInt32BE(color, index * 4)
}
}
cache.set(cacheKey, buffer)
resolve(buffer)
})
})
}
/**
* Converts an imageBuffer into a base64 encoded PNG string for use as a buttons png64 styling
*
* @param options - Required and optional params
* @returns An base64 encoded PNG
*/
export const toPNG64 = (options: OptionToPNG64): string => {
const png = new PNG({
width: options.width,
height: options.height,
filterType: -1,
inputHasAlpha: true,
colorType: 6,
deflateLevel: 0,
})
const newBuffer = Buffer.alloc(options.width * options.height * 4)
options.image.forEach((value, index) => {
const position = ((index + 3) % 4) + Math.floor(index / 4) * 4
newBuffer[position] = value
})
png.data = newBuffer
const pngBuffer = PNG.sync.write(png)
return `data:image/png;base64,${pngBuffer.toString('base64')}`
}