Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: prepared buffers #102

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -16,6 +16,7 @@ import type { PropertiesService } from './services/properties/interface.js'
export * from './types.js'
export * from './id.js'
export * from './controlDefinition.js'
export type { PreparedBuffer } from './preparedBuffer.js'
export type { HIDDevice, HIDDeviceInfo, HIDDeviceEvents, ChildHIDDeviceInfo } from './hid-device.js'
export type { OpenStreamDeckOptions } from './models/base.js'
export { StreamDeckProxy } from './proxy.js'
31 changes: 31 additions & 0 deletions packages/core/src/models/base.ts
Original file line number Diff line number Diff line change
@@ -17,6 +17,7 @@ import type { CallbackHook } from '../services/callback-hook.js'
import type { StreamDeckInputService } from '../services/input/interface.js'
import { DEVICE_MODELS, VENDOR_ID } from '../index.js'
import type { EncoderLedService } from '../services/encoderLed.js'
import { unwrapPreparedBufferToBuffer, type PreparedBuffer } from '../preparedBuffer.js'

export type EncodeJPEGHelper = (buffer: Uint8Array, width: number, height: number) => Promise<Uint8Array>

@@ -166,6 +167,11 @@ export class StreamDeckBase extends EventEmitter<StreamDeckEvents> implements St
return this.#propertiesService.getSerialNumber()
}

public async sendPreparedBuffer(buffer: PreparedBuffer): Promise<void> {
const packets = unwrapPreparedBufferToBuffer(this.deviceProperties.MODEL, buffer)
await this.device.sendReports(packets)
}

public async fillKeyColor(keyIndex: KeyIndex, r: number, g: number, b: number): Promise<void> {
this.checkValidKeyIndex(keyIndex, null)

@@ -178,10 +184,27 @@ export class StreamDeckBase extends EventEmitter<StreamDeckEvents> implements St
await this.#buttonsLcdService.fillKeyBuffer(keyIndex, imageBuffer, options)
}

public async prepareFillKeyBuffer(
keyIndex: KeyIndex,
imageBuffer: Uint8Array | Uint8ClampedArray,
options?: FillImageOptions,
jsonSafe?: boolean,
): Promise<PreparedBuffer> {
return this.#buttonsLcdService.prepareFillKeyBuffer(keyIndex, imageBuffer, options, jsonSafe)
}

public async fillPanelBuffer(imageBuffer: Uint8Array, options?: FillPanelOptions): Promise<void> {
await this.#buttonsLcdService.fillPanelBuffer(imageBuffer, options)
}

public async prepareFillPanelBuffer(
imageBuffer: Uint8Array | Uint8ClampedArray,
options?: FillPanelOptions,
jsonSafe?: boolean,
): Promise<PreparedBuffer> {
return this.#buttonsLcdService.prepareFillPanelBuffer(imageBuffer, options, jsonSafe)
}

public async clearKey(keyIndex: KeyIndex): Promise<void> {
this.checkValidKeyIndex(keyIndex, null)

@@ -212,6 +235,14 @@ export class StreamDeckBase extends EventEmitter<StreamDeckEvents> implements St
return this.#lcdSegmentDisplayService.fillLcdRegion(...args)
}

public async prepareFillLcdRegion(
...args: Parameters<StreamDeck['prepareFillLcdRegion']>
): ReturnType<StreamDeck['prepareFillLcdRegion']> {
if (!this.#lcdSegmentDisplayService) throw new Error('Not supported for this model')

return this.#lcdSegmentDisplayService.prepareFillLcdRegion(...args)
}

public async clearLcdSegment(
...args: Parameters<StreamDeck['clearLcdSegment']>
): ReturnType<StreamDeck['clearLcdSegment']> {
64 changes: 64 additions & 0 deletions packages/core/src/preparedBuffer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { DeviceModelId } from './id.js'

/**
* This represents a buffer that has been prepared for sending to a Stream Deck.
* Note: The result is only guaranteed to be valid for this specific StreamDeck and the same library version, but is safe to store externally.
* If it sent to the wrong model, the result is undefined behaviour.
*
* This is an opaque type, and should not be viewed/inspected directly.
*
* It may be serialized to JSON, but only if it was generated with the `jsonSafe` flag set to `true`.
*/
export interface PreparedBuffer {
readonly __internal__: never
}

interface PreparedButtonDrawInternal {
if_you_change_this_you_will_break_everything: string
modelId: DeviceModelId
type: string
do_not_touch: Uint8Array[] | string[]
}

export function wrapBufferToPreparedBuffer(
modelId: DeviceModelId,
type: string,
buffers: Uint8Array[],
jsonSafe: boolean,
): PreparedBuffer {
let encodedBuffers: PreparedButtonDrawInternal['do_not_touch'] = buffers

if (jsonSafe) {
const decoder = new TextDecoder()
encodedBuffers = buffers.map((b) => decoder.decode(b))
}

return {
if_you_change_this_you_will_break_everything:
'This is a encoded form of the buffer, exactly as the Stream Deck expects it. Do not touch this object, or you can crash your stream deck',
modelId,
type,
do_not_touch: encodedBuffers,
} satisfies PreparedButtonDrawInternal as any
}

export function unwrapPreparedBufferToBuffer(
modelId: DeviceModelId,
// type: string,
prepared: PreparedBuffer,
): Uint8Array[] {
const preparedInternal = prepared as any as PreparedButtonDrawInternal
if (preparedInternal.modelId !== modelId) throw new Error('Prepared buffer is for a different model!')

// if (preparedInternal.type !== type) throw new Error('Prepared buffer is for a different type!')

return preparedInternal.do_not_touch.map((b) => {
if (typeof b === 'string') {
return new TextEncoder().encode(b)
} else if (b instanceof Uint8Array) {
return b
} else {
throw new Error('Prepared buffer is not a string or Uint8Array!')
}
})
}
21 changes: 21 additions & 0 deletions packages/core/src/proxy.ts
Original file line number Diff line number Diff line change
@@ -48,6 +48,11 @@ export class StreamDeckProxy implements StreamDeck {
): ReturnType<StreamDeck['getHidDeviceInfo']> {
return this.device.getHidDeviceInfo(...args)
}
public async sendPreparedBuffer(
...args: Parameters<StreamDeck['sendPreparedBuffer']>
): ReturnType<StreamDeck['sendPreparedBuffer']> {
return this.device.sendPreparedBuffer(...args)
}
public async fillKeyColor(...args: Parameters<StreamDeck['fillKeyColor']>): ReturnType<StreamDeck['fillKeyColor']> {
return this.device.fillKeyColor(...args)
}
@@ -56,11 +61,21 @@ export class StreamDeckProxy implements StreamDeck {
): ReturnType<StreamDeck['fillKeyBuffer']> {
return this.device.fillKeyBuffer(...args)
}
public async prepareFillKeyBuffer(
...args: Parameters<StreamDeck['prepareFillKeyBuffer']>
): ReturnType<StreamDeck['prepareFillKeyBuffer']> {
return this.device.prepareFillKeyBuffer(...args)
}
public async fillPanelBuffer(
...args: Parameters<StreamDeck['fillPanelBuffer']>
): ReturnType<StreamDeck['fillPanelBuffer']> {
return this.device.fillPanelBuffer(...args)
}
public async prepareFillPanelBuffer(
...args: Parameters<StreamDeck['prepareFillPanelBuffer']>
): ReturnType<StreamDeck['prepareFillPanelBuffer']> {
return this.device.prepareFillPanelBuffer(...args)
}
public async clearKey(...args: Parameters<StreamDeck['clearKey']>): ReturnType<StreamDeck['clearKey']> {
return this.device.clearKey(...args)
}
@@ -113,6 +128,12 @@ export class StreamDeckProxy implements StreamDeck {
return this.device.fillLcdRegion(...args)
}

public async prepareFillLcdRegion(
...args: Parameters<StreamDeck['prepareFillLcdRegion']>
): ReturnType<StreamDeck['prepareFillLcdRegion']> {
return this.device.prepareFillLcdRegion(...args)
}

public async clearLcdSegment(
...args: Parameters<StreamDeck['clearLcdSegment']>
): ReturnType<StreamDeck['clearLcdSegment']> {
74 changes: 65 additions & 9 deletions packages/core/src/services/buttonsLcdDisplay/default.ts
Original file line number Diff line number Diff line change
@@ -9,6 +9,7 @@ import type { FillPanelDimensionsOptions, FillImageOptions, FillPanelOptions } f
import type { StreamdeckImageWriter } from '../imageWriter/types.js'
import type { ButtonsLcdDisplayService, GridSpan } from './interface.js'
import type { ButtonLcdImagePacker, InternalFillImageOptions } from '../imagePacker/interface.js'
import { wrapBufferToPreparedBuffer, type PreparedBuffer } from '../../preparedBuffer.js'

export class DefaultButtonsLcdService implements ButtonsLcdDisplayService {
readonly #imageWriter: StreamdeckImageWriter
@@ -101,6 +102,7 @@ export class DefaultButtonsLcdService implements ButtonsLcdDisplayService {
ps.push(this.sendKeyRgb(control.hidIndex, 0, 0, 0))
} else {
const pixels = new Uint8Array(control.pixelSize.width * control.pixelSize.height * 3)
// TODO - caching?
ps.push(
this.fillImageRangeControl(control, pixels, {
format: 'rgb',
@@ -132,6 +134,7 @@ export class DefaultButtonsLcdService implements ButtonsLcdDisplayService {
await this.sendKeyRgb(keyIndex, 0, 0, 0)
} else {
const pixels = new Uint8Array(control.pixelSize.width * control.pixelSize.height * 3)
// TODO - caching?
await this.fillImageRangeControl(control, pixels, {
format: 'rgb',
offset: 0,
@@ -180,7 +183,20 @@ export class DefaultButtonsLcdService implements ButtonsLcdDisplayService {
}
}

public async fillKeyBuffer(keyIndex: KeyIndex, imageBuffer: Uint8Array, options?: FillImageOptions): Promise<void> {
public async fillKeyBuffer(
keyIndex: KeyIndex,
imageBuffer: Uint8Array | Uint8ClampedArray,
options?: FillImageOptions,
): Promise<void> {
const packets = await this.prepareFillKeyBufferInner(keyIndex, imageBuffer, options)
await this.#device.sendReports(packets)
}

private async prepareFillKeyBufferInner(
keyIndex: KeyIndex,
imageBuffer: Uint8Array | Uint8ClampedArray,
options: FillImageOptions | undefined,
): Promise<Uint8Array[]> {
const sourceFormat = options?.format ?? 'rgb'
this.checkSourceFormat(sourceFormat)

@@ -198,14 +214,35 @@ export class DefaultButtonsLcdService implements ButtonsLcdDisplayService {
throw new RangeError(`Expected image buffer of length ${imageSize}, got length ${imageBuffer.length}`)
}

await this.fillImageRangeControl(control, imageBuffer, {
return this.prepareFillImageRangeControl(control, imageBuffer, {
format: sourceFormat,
offset: 0,
stride: control.pixelSize.width * sourceFormat.length,
})
}

public async fillPanelBuffer(imageBuffer: Uint8Array, options?: FillPanelOptions): Promise<void> {
public async prepareFillKeyBuffer(
keyIndex: KeyIndex,
imageBuffer: Uint8Array | Uint8ClampedArray,
options: FillImageOptions | undefined,
jsonSafe: boolean | undefined,
): Promise<PreparedBuffer> {
const packets = await this.prepareFillKeyBufferInner(keyIndex, imageBuffer, options)
return wrapBufferToPreparedBuffer(this.#deviceProperties.MODEL, 'fill-key', packets, jsonSafe ?? false)
}

public async fillPanelBuffer(
imageBuffer: Uint8Array | Uint8ClampedArray,
options?: FillPanelOptions,
): Promise<void> {
const packets = await this.prepareFillPanelBufferInner(imageBuffer, options)
await this.#device.sendReports(packets)
}

private async prepareFillPanelBufferInner(
imageBuffer: Uint8Array | Uint8ClampedArray,
options?: FillPanelOptions,
): Promise<Uint8Array[]> {
const sourceFormat = options?.format ?? 'rgb'
this.checkSourceFormat(sourceFormat)

@@ -231,7 +268,7 @@ export class DefaultButtonsLcdService implements ButtonsLcdDisplayService {

const stride = panelDimensions.width * sourceFormat.length

const ps: Array<Promise<void>> = []
const ps: Array<Promise<Uint8Array[]>> = []
for (const control of buttonLcdControls) {
const controlRow = control.row - panelGridSpan.minRow
const controlCol = control.column - panelGridSpan.minCol
@@ -244,14 +281,25 @@ export class DefaultButtonsLcdService implements ButtonsLcdDisplayService {

// TODO: Implement padding
ps.push(
this.fillImageRangeControl(control, imageBuffer, {
this.prepareFillImageRangeControl(control, imageBuffer, {
format: sourceFormat,
offset: rowOffset + colOffset,
stride,
}),
)
}
await Promise.all(ps)

const packets = await Promise.all(ps)
return packets.flat()
}

public async prepareFillPanelBuffer(
imageBuffer: Uint8Array | Uint8ClampedArray,
options: FillPanelOptions | undefined,
jsonSafe: boolean | undefined,
): Promise<PreparedBuffer> {
const packets = await this.prepareFillPanelBufferInner(imageBuffer, options)
return wrapBufferToPreparedBuffer(this.#deviceProperties.MODEL, 'fill-panel', packets, jsonSafe ?? false)
}

private async sendKeyRgb(keyIndex: number, red: number, green: number, blue: number): Promise<void> {
@@ -260,9 +308,18 @@ export class DefaultButtonsLcdService implements ButtonsLcdDisplayService {

private async fillImageRangeControl(
buttonControl: StreamDeckButtonControlDefinitionLcdFeedback,
imageBuffer: Uint8Array,
imageBuffer: Uint8Array | Uint8ClampedArray,
sourceOptions: InternalFillImageOptions,
) {
const packets = await this.prepareFillImageRangeControl(buttonControl, imageBuffer, sourceOptions)
await this.#device.sendReports(packets)
}

private async prepareFillImageRangeControl(
buttonControl: StreamDeckButtonControlDefinitionLcdFeedback,
imageBuffer: Uint8Array | Uint8ClampedArray,
sourceOptions: InternalFillImageOptions,
): Promise<Uint8Array[]> {
if (buttonControl.feedbackType !== 'lcd')
throw new TypeError(`keyIndex ${buttonControl.index} does not support lcd feedback`)

@@ -272,8 +329,7 @@ export class DefaultButtonsLcdService implements ButtonsLcdDisplayService {
buttonControl.pixelSize,
)

const packets = this.#imageWriter.generateFillImageWrites({ keyIndex: buttonControl.hidIndex }, byteBuffer)
await this.#device.sendReports(packets)
return this.#imageWriter.generateFillImageWrites({ keyIndex: buttonControl.hidIndex }, byteBuffer)
}

private checkRGBValue(value: number): void {
13 changes: 13 additions & 0 deletions packages/core/src/services/buttonsLcdDisplay/interface.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Dimension, KeyIndex } from '../../id.js'
import type { FillImageOptions, FillPanelDimensionsOptions, FillPanelOptions } from '../../types.js'
import type { PreparedBuffer } from '../../preparedBuffer.js'

export interface GridSpan {
minRow: number
@@ -16,5 +17,17 @@ export interface ButtonsLcdDisplayService {

fillKeyColor(keyIndex: KeyIndex, r: number, g: number, b: number): Promise<void>
fillKeyBuffer(keyIndex: KeyIndex, imageBuffer: Uint8Array, options?: FillImageOptions): Promise<void>
prepareFillKeyBuffer(
keyIndex: KeyIndex,
imageBuffer: Uint8Array | Uint8ClampedArray,
options: FillImageOptions | undefined,
jsonSafe: boolean | undefined,
): Promise<PreparedBuffer>

fillPanelBuffer(imageBuffer: Uint8Array, options: FillPanelOptions | undefined): Promise<void>
prepareFillPanelBuffer(
imageBuffer: Uint8Array | Uint8ClampedArray,
options: FillPanelOptions | undefined,
jsonSafe: boolean | undefined,
): Promise<PreparedBuffer>
}
20 changes: 19 additions & 1 deletion packages/core/src/services/buttonsLcdDisplay/pedal.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Dimension } from '../../id.js'
import type { Dimension, KeyIndex } from '../../id.js'
import type { ButtonsLcdDisplayService } from './interface.js'
import type { FillPanelDimensionsOptions, FillImageOptions, FillPanelOptions } from '../../types.js'
import type { PreparedBuffer } from '../../preparedBuffer.js'

export class PedalLcdService implements ButtonsLcdDisplayService {
public calculateFillPanelDimensions(_options?: FillPanelDimensionsOptions): Dimension | null {
@@ -23,7 +24,24 @@ export class PedalLcdService implements ButtonsLcdDisplayService {
): Promise<void> {
// Not supported
}
public async prepareFillKeyBuffer(
_keyIndex: KeyIndex,
_imageBuffer: Uint8Array | Uint8ClampedArray,
_options: FillImageOptions | undefined,
_jsonSafe: boolean | undefined,
): Promise<PreparedBuffer> {
// Not supported
throw new Error('Not supported')
}
public async fillPanelBuffer(_imageBuffer: Uint8Array, _options?: FillPanelOptions): Promise<void> {
// Not supported
}
public async prepareFillPanelBuffer(
_imageBuffer: Uint8Array | Uint8ClampedArray,
_options: FillPanelOptions | undefined,
_jsonSafe: boolean | undefined,
): Promise<PreparedBuffer> {
// Not supported
throw new Error('Not supported')
}
}
Loading