Skip to content

Commit

Permalink
feat: support external colour conversions in uploadStill and uploadCl…
Browse files Browse the repository at this point in the history
…ip api (#160)
  • Loading branch information
Julusian authored Feb 7, 2024
1 parent 66979fb commit 038a28c
Show file tree
Hide file tree
Showing 8 changed files with 120 additions and 40 deletions.
63 changes: 47 additions & 16 deletions src/atem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import PLazy = require('p-lazy')
import { TimeCommand } from './commands'
import { TimeInfo } from './state/info'
import { SomeAtemAudioLevels } from './state/levels'
import { generateUploadBufferInfo, UploadBufferInfo } from './dataTransfer/dataTransferUploadBuffer'

export interface AtemOptions {
address?: string
Expand Down Expand Up @@ -748,42 +749,72 @@ export class Atem extends BasicAtem {
return this.sendCommand(command)
}

/**
* Upload a still image to the ATEM media pool
*
* Note: This performs colour conversions in JS, which is not very CPU efficient. If performance is important,
* consider using [@atem-connection/image-tools](https://www.npmjs.com/package/@atem-connection/image-tools) to
* pre-convert the images with more optimal algorithms
* @param index Still index to upload to
* @param data a RGBA pixel buffer, or an already YUVA encoded image
* @param name Name to give the uploaded image
* @param description Description for the uploaded image
* @param options Upload options
* @returns Promise which resolves once the image is uploaded
*/
public async uploadStill(
index: number,
data: Buffer,
data: Buffer | UploadBufferInfo,
name: string,
description: string,
options?: DT.UploadStillEncodingOptions
): Promise<void> {
if (!this.state) return Promise.reject()
if (!this.state) throw new Error('Unable to check current resolution')
const resolution = Util.getVideoModeInfo(this.state.settings.videoMode)
if (!resolution) return Promise.reject()
return this.dataTransferManager.uploadStill(
index,
Util.convertRGBAToYUV422(resolution.width, resolution.height, data),
name,
description,
options
)
if (!resolution) throw new Error('Failed to determine required resolution')

const encodedData = generateUploadBufferInfo(data, resolution, !options?.disableRLE)

return this.dataTransferManager.uploadStill(index, encodedData, name, description)
}

/**
* Upload a clip to the ATEM media pool
*
* Note: This performs colour conversions in JS, which is not very CPU efficient. If performance is important,
* consider using [@atem-connection/image-tools](https://www.npmjs.com/package/@atem-connection/image-tools) to
* pre-convert the images with more optimal algorithms
* @param index Clip index to upload to
* @param frames Array or generator of frames. Each frame can be a RGBA pixel buffer, or an already YUVA encoded image
* @param name Name to give the uploaded clip
* @param options Upload options
* @returns Promise which resolves once the clip is uploaded
*/
public async uploadClip(
index: number,
frames: Iterable<Buffer> | AsyncIterable<Buffer>,
frames: Iterable<Buffer> | AsyncIterable<Buffer> | Iterable<UploadBufferInfo> | AsyncIterable<UploadBufferInfo>,
name: string,
options?: DT.UploadStillEncodingOptions
): Promise<void> {
if (!this.state) return Promise.reject()
if (!this.state) throw new Error('Unable to check current resolution')
const resolution = Util.getVideoModeInfo(this.state.settings.videoMode)
if (!resolution) return Promise.reject()
const provideFrame = async function* (): AsyncGenerator<Buffer> {
if (!resolution) throw new Error('Failed to determine required resolution')

const provideFrame = async function* (): AsyncGenerator<UploadBufferInfo> {
for await (const frame of frames) {
yield Util.convertRGBAToYUV422(resolution.width, resolution.height, frame)
yield generateUploadBufferInfo(frame, resolution, !options?.disableRLE)
}
}
return this.dataTransferManager.uploadClip(index, provideFrame(), name, options)
return this.dataTransferManager.uploadClip(index, provideFrame(), name)
}

/**
* Upload clip audio to the ATEM media pool
* @param index Clip index to upload to
* @param data stereo 48khz 24bit WAV audio data
* @param name Name to give the uploaded audio
* @returns Promise which resolves once the clip audio is uploaded
*/
public async uploadAudio(index: number, data: Buffer, name: string): Promise<void> {
return this.dataTransferManager.uploadAudio(index, Util.convertWAVToRaw(data, this.state?.info?.model), name)
}
Expand Down
19 changes: 16 additions & 3 deletions src/dataTransfer/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import * as DataTransferCommands from '../../commands/DataTransfer'
import { readFileSync } from 'fs'
import * as path from 'path'
import { DataTransferManager } from '..'
import { Commands } from '../..'
import { Commands, UploadBufferInfo } from '../..'
import { generateHashForBuffer } from '../dataTransferUploadBuffer'

function specToCommandClass(spec: any): Commands.IDeserializedCommand | undefined {
for (const commandName in DataTransferCommands) {
Expand Down Expand Up @@ -59,9 +60,15 @@ test('Still upload', async () => {
const spec: any[] = JSON.parse(readFileSync(path.join(__dirname, './upload-still-sequence.json')).toString())

const newBuffer = Buffer.alloc(1920 * 1080 * 4, 0)
const wrappedBuffer: UploadBufferInfo = {
encodedData: newBuffer,
rawDataLength: newBuffer.length,
isRleEncoded: false,
hash: generateHashForBuffer(newBuffer),
}

const manager = runDataTransferTest(spec)
await manager.uploadStill(2, newBuffer, 'some still', '', { disableRLE: true })
await manager.uploadStill(2, wrappedBuffer, 'some still', '')

await new Promise((resolve) => setTimeout(resolve, 200))

Expand All @@ -87,9 +94,15 @@ test('clip upload', async () => {
const spec: any[] = JSON.parse(readFileSync(path.join(__dirname, './upload-clip-sequence.json')).toString())

const newBuffer = Buffer.alloc(1920 * 1080 * 4, 0)
const wrappedBuffer: UploadBufferInfo = {
encodedData: newBuffer,
rawDataLength: newBuffer.length,
isRleEncoded: false,
hash: generateHashForBuffer(newBuffer),
}

const manager = runDataTransferTest(spec)
await manager.uploadClip(1, [newBuffer, newBuffer, newBuffer], 'clip file', { disableRLE: true })
await manager.uploadClip(1, [wrappedBuffer, wrappedBuffer, wrappedBuffer], 'clip file')

await new Promise((resolve) => setTimeout(resolve, 200))

Expand Down
1 change: 1 addition & 0 deletions src/dataTransfer/dataTransferUploadAudio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export default class DataTransferUploadAudio extends DataTransferUploadBuffer {
super({
encodedData: data,
rawDataLength: data.length,
isRleEncoded: false,
hash: null,
})

Expand Down
51 changes: 46 additions & 5 deletions src/dataTransfer/dataTransferUploadBuffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,61 @@ import * as Util from '../lib/atemUtil'
const debug = debug0('atem-connection:data-transfer:upload-buffer')

export interface UploadBufferInfo {
/**
* Encoded data in ATEM native format (eg YUVA for pixels, 24bit audio)
*/
encodedData: Buffer
/**
* Length of the encoded data, before any RLE encoding
*/
rawDataLength: number
/**
* Whether RLE encoding has been performed on this buffer (when supported)
*/
isRleEncoded: boolean
/**
* Hash for the encoded data, intended as a unique identifier/checksum
* When `null`, one will be generated from the `encodedData`
* This is returned by the ATEM when describing what is in each slot
*/
hash: string | null
}

export function generateHashForBuffer(data: Buffer): string {
return data ? crypto.createHash('md5').update(data).digest('base64') : ''
}

export function generateBufferInfo(data: Buffer, shouldEncodeRLE: boolean): UploadBufferInfo {
return {
encodedData: shouldEncodeRLE ? Util.encodeRLE(data) : data,
rawDataLength: data.length,
hash: generateHashForBuffer(data),
export function generateUploadBufferInfo(
data: Buffer | UploadBufferInfo,
resolution: Util.VideoModeInfo,
shouldEncodeRLE: boolean
): UploadBufferInfo {
const expectedLength = resolution.width * resolution.height * 4
if (Buffer.isBuffer(data)) {
if (data.length !== expectedLength)
throw new Error(`Pixel buffer has incorrect length. Received ${data.length} expected ${expectedLength}`)

const encodedData = Util.convertRGBAToYUV422(resolution.width, resolution.height, data)

return {
encodedData: shouldEncodeRLE ? Util.encodeRLE(encodedData) : encodedData,
rawDataLength: encodedData.length,
isRleEncoded: shouldEncodeRLE,
hash: generateHashForBuffer(encodedData),
}
} else {
const result: UploadBufferInfo = { ...data }
if (data.rawDataLength !== expectedLength)
throw new Error(
`Pixel buffer has incorrect length. Received ${data.rawDataLength} expected ${expectedLength}`
)

if (shouldEncodeRLE && !data.isRleEncoded) {
data.isRleEncoded = true
data.encodedData = Util.encodeRLE(data.encodedData)
}

return result
}
}

Expand Down
1 change: 1 addition & 0 deletions src/dataTransfer/dataTransferUploadMacro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export class DataTransferUploadMacro extends DataTransferUploadBuffer {
super({
encodedData: data,
rawDataLength: data.length,
isRleEncoded: false,
hash: null,
})
}
Expand Down
1 change: 1 addition & 0 deletions src/dataTransfer/dataTransferUploadMultiViewerLabel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export default class DataTransferUploadMultiViewerLabel extends DataTransferUplo
super({
encodedData: data,
rawDataLength: data.length,
isRleEncoded: false,
hash: null,
})

Expand Down
21 changes: 6 additions & 15 deletions src/dataTransfer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { DataTransferDownloadMacro } from './dataTransferDownloadMacro'
import { DataTransferUploadMacro } from './dataTransferUploadMacro'
import { LockObtainedCommand, LockStateUpdateCommand } from '../commands/DataTransfer'
import debug0 from 'debug'
import { generateBufferInfo } from './dataTransferUploadBuffer'
import type { UploadBufferInfo } from './dataTransferUploadBuffer'

const MAX_PACKETS_TO_SEND_PER_TICK = 50
const MAX_TRANSFER_INDEX = (1 << 16) - 1 // Inclusive maximum
Expand Down Expand Up @@ -170,30 +170,21 @@ export class DataTransferManager {
}
}

public async uploadStill(
index: number,
data: Buffer,
name: string,
description: string,
options?: UploadStillEncodingOptions
): Promise<void> {
const buffer = generateBufferInfo(data, !options?.disableRLE)
const transfer = new DataTransferUploadStill(index, buffer, name, description)
public async uploadStill(index: number, data: UploadBufferInfo, name: string, description: string): Promise<void> {
const transfer = new DataTransferUploadStill(index, data, name, description)
return this.#stillsLock.enqueue(transfer)
}

public async uploadClip(
index: number,
data: Iterable<Buffer> | AsyncIterable<Buffer>,
name: string,
options?: UploadStillEncodingOptions
data: Iterable<UploadBufferInfo> | AsyncIterable<UploadBufferInfo>,
name: string
): Promise<void> {
const provideFrame = async function* (): AsyncGenerator<DataTransferUploadClipFrame, undefined> {
let id = -1
for await (const frame of data) {
id++
const buffer = generateBufferInfo(frame, !options?.disableRLE)
yield new DataTransferUploadClipFrame(index, id, buffer)
yield new DataTransferUploadClipFrame(index, id, frame)
}
return undefined
}
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ import * as InputState from './state/input'
import * as MacroState from './state/macro'
import * as SettingsState from './state/settings'
export { VideoState, AudioState, MediaState, InfoState, InputState, MacroState, SettingsState }
export { UploadStillEncodingOptions } from './dataTransfer'
export type { UploadStillEncodingOptions } from './dataTransfer'
export type { UploadBufferInfo } from './dataTransfer/dataTransferUploadBuffer'

0 comments on commit 038a28c

Please sign in to comment.