Skip to content

Commit b9938dd

Browse files
committed
experiment: dsk plugin support
1 parent 573cf4a commit b9938dd

12 files changed

Lines changed: 292 additions & 10 deletions

File tree

packages/timeline-state-resolver-types/src/generated/obs.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ export interface MappingObsInputMedia {
6363
mappingType: MappingObsType.InputMedia
6464
}
6565

66+
export interface MappingObsDownstreamKeyer {
67+
mappingType: MappingObsType.DownstreamKeyer
68+
}
69+
6670
export enum MappingObsType {
6771
CurrentScene = 'currentScene',
6872
CurrentTransition = 'currentTransition',
@@ -72,9 +76,10 @@ export enum MappingObsType {
7276
InputAudio = 'inputAudio',
7377
InputSettings = 'inputSettings',
7478
InputMedia = 'inputMedia',
79+
DownstreamKeyer = 'downstreamKeyer',
7580
}
7681

77-
export type SomeMappingObs = MappingObsCurrentScene | MappingObsCurrentTransition | MappingObsRecording | MappingObsStreaming | MappingObsSceneItem | MappingObsInputAudio | MappingObsInputSettings | MappingObsInputMedia
82+
export type SomeMappingObs = MappingObsCurrentScene | MappingObsCurrentTransition | MappingObsRecording | MappingObsStreaming | MappingObsSceneItem | MappingObsInputAudio | MappingObsInputSettings | MappingObsInputMedia | MappingObsDownstreamKeyer
7883

7984
export interface ObsDeviceTypes {
8085
Options: ObsOptions

packages/timeline-state-resolver-types/src/integrations/obs.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export type TimelineContentOBSAny =
55
| TimelineContentOBSCurrentTransition
66
| TimelineContentOBSRecording
77
| TimelineContentOBSStreaming
8+
| TimelineContentOBSDownstreamKeyer
89
| TimelineContentOBSSceneItem
910
| TimelineContentOBSInputAudio
1011
| TimelineContentOBSInputSettings
@@ -16,6 +17,8 @@ export enum TimelineContentTypeOBS {
1617
RECORDING = 'RECORDING',
1718
STREAMING = 'STREAMING',
1819

20+
DOWNSTREAM_KEYER = 'DOWNSTREAM_KEYER',
21+
1922
SCENE_ITEM = 'SCENE_ITEM',
2023

2124
INPUT_AUDIO = 'INPUT_AUDIO',
@@ -55,6 +58,19 @@ export interface TimelineContentOBSStreaming extends TimelineContentOBSBase {
5558
on: boolean
5659
}
5760

61+
export interface TimelineContentOBSDownstreamKeyer extends TimelineContentOBSBase {
62+
type: TimelineContentTypeOBS.DOWNSTREAM_KEYER
63+
64+
/** Name of the DSK scene that should be selected */
65+
sceneName: string
66+
67+
/**
68+
* Ensure `sceneName` exists in the DSK list before selecting it.
69+
* Defaults to `true`.
70+
*/
71+
ensureInDskList?: boolean
72+
}
73+
5874
export interface TimelineContentOBSSceneItem extends TimelineContentOBSBase {
5975
deviceType: DeviceType.OBS
6076
type: TimelineContentTypeOBS.SCENE_ITEM

packages/timeline-state-resolver/src/integrations/obs/$schemas/mappings.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@
7878
},
7979
"required": ["input"],
8080
"additionalProperties": false
81+
},
82+
"downstreamKeyer": {
83+
"type": "object",
84+
"properties": {},
85+
"required": [],
86+
"additionalProperties": false
8187
}
8288
}
8389
}

packages/timeline-state-resolver/src/integrations/obs/__tests__/obs.spec.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,44 @@ describe('OBS Device', () => {
369369
]
370370
)
371371
})
372+
373+
test('Downstream Keyer select scene', async () => {
374+
await compareStates(
375+
getDefaultState(10),
376+
{
377+
...getDefaultState(20),
378+
dsk: {
379+
selectedScene: 'gfx_lowerthird',
380+
},
381+
},
382+
[
383+
{
384+
command: {
385+
requestName: 'CallVendorRequest',
386+
args: {
387+
vendorName: 'downstream-keyer',
388+
requestType: 'dsk_add_scene',
389+
requestData: { scene: 'gfx_lowerthird' },
390+
},
391+
},
392+
timelineObjId: '',
393+
context: 'dsk add scene "gfx_lowerthird"',
394+
},
395+
{
396+
command: {
397+
requestName: 'CallVendorRequest',
398+
args: {
399+
vendorName: 'downstream-keyer',
400+
requestType: 'dsk_select_scene',
401+
requestData: { scene: 'gfx_lowerthird' },
402+
},
403+
},
404+
timelineObjId: '',
405+
context: 'dsk select scene changed from "undefined" to "gfx_lowerthird"',
406+
},
407+
]
408+
)
409+
})
372410
})
373411
})
374412

packages/timeline-state-resolver/src/integrations/obs/connection.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { EventEmitter } from 'node:events'
22
import OBSWebSocket, { OBSRequestTypes, OBSResponseTypes } from 'obs-websocket-js'
3+
import type { OBSDownstreamKeyerCache } from './downstreamKeyer/types'
4+
import { refreshDownstreamKeyerCache } from './downstreamKeyer/discovery'
35

46
const RECONNECT_TIME = 5000
57

@@ -22,6 +24,7 @@ export class OBSConnection extends EventEmitter<OBSConnectionEventsTypes> {
2224
private _reconnect_wait = 0
2325
private _reconnect_timeout: NodeJS.Timeout | undefined
2426
private _sceneItemMap = new Map<string, number>()
27+
private _dskCache: OBSDownstreamKeyerCache | undefined
2528

2629
connected = false
2730
error: string | undefined = undefined
@@ -58,13 +61,21 @@ export class OBSConnection extends EventEmitter<OBSConnectionEventsTypes> {
5861
this._reconnect_wait = 0
5962
if (this._reconnect_timeout) clearTimeout(this._reconnect_timeout)
6063

61-
this._buildAndTrackSceneItemIDs()
62-
.catch((e) => {
63-
this.emit(OBSConnectionEvents.Error, 'Error trying to rebuild SceneItemID map', e)
64-
})
65-
.finally(() => {
66-
this.emit(OBSConnectionEvents.Connected)
64+
Promise.allSettled([this._buildAndTrackSceneItemIDs(), this._refreshDownstreamKeyerCache()])
65+
.then((results) => {
66+
const [sceneItemRes, dskRes] = results
67+
if (sceneItemRes.status === 'rejected') {
68+
this.emit(OBSConnectionEvents.Error, 'Error trying to rebuild SceneItemID map', sceneItemRes.reason)
69+
}
70+
if (dskRes.status === 'rejected') {
71+
this.emit(
72+
OBSConnectionEvents.Error,
73+
'Error trying to refresh Downstream Keyer cache',
74+
dskRes.reason
75+
)
76+
}
6777
})
78+
.finally(() => this.emit(OBSConnectionEvents.Connected))
6879
})
6980
.catch((e) => {
7081
this.connected = false
@@ -119,6 +130,10 @@ export class OBSConnection extends EventEmitter<OBSConnectionEventsTypes> {
119130
return this._sceneItemMap.get(scene + '_' + input)
120131
}
121132

133+
getDownstreamKeyerCache(): OBSDownstreamKeyerCache | undefined {
134+
return this._dskCache
135+
}
136+
122137
private async _buildAndTrackSceneItemIDs() {
123138
const sceneItemMap = new Map<string, number>()
124139
const scenes = await this._obs.call('GetSceneList')
@@ -133,4 +148,11 @@ export class OBSConnection extends EventEmitter<OBSConnectionEventsTypes> {
133148

134149
this._sceneItemMap = sceneItemMap
135150
}
151+
152+
private async _refreshDownstreamKeyerCache() {
153+
// Not a hard requirement for connecting; we can still operate by "add before select".
154+
this._dskCache = await refreshDownstreamKeyerCache((requestType, requestData) =>
155+
this._obs.call(requestType, requestData as any)
156+
)
157+
}
136158
}

packages/timeline-state-resolver/src/integrations/obs/diff.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@ import _ = require('underscore')
22
import { OBSCommandWithContext, OBSCommandWithContextTyped } from '.'
33
import { OBSDeviceState, OBSScene, OBSSceneItem, OBSInputState } from './state'
44
import { literal } from '../../lib'
5+
import { diffDownstreamKeyer } from './downstreamKeyer/diff'
56

67
export function diffStates(
78
oldState: OBSDeviceState,
89
newState: OBSDeviceState,
9-
getSceneItemId: (scene: string, source: string) => number | undefined
10+
getSceneItemId: (scene: string, source: string) => number | undefined,
11+
getDownstreamKeyerCache?: () => import('./downstreamKeyer/types').OBSDownstreamKeyerCache | undefined
1012
): Array<OBSCommandWithContext> {
1113
const commands: Array<OBSCommandWithContext> = [
1214
...resolveCurrentSceneState(oldState, newState),
1315
...resolveCurrentTransitionState(oldState, newState),
1416
...resolveRecordingStreaming(oldState, newState),
17+
...diffDownstreamKeyer(oldState, newState, getDownstreamKeyerCache?.()),
1518
...resolveScenes(oldState, newState, getSceneItemId),
1619
...resolveInputSettings(oldState, newState),
1720
]
@@ -368,6 +371,7 @@ export enum OBSRequestName {
368371
STOP_RECORDING = 'StopRecord',
369372
START_STREAMING = 'StartStream',
370373
STOP_STREAMING = 'StopStream',
374+
CALL_VENDOR_REQUEST = 'CallVendorRequest',
371375
SET_SCENE_ITEM_ENABLED = 'SetSceneItemEnabled',
372376
SET_SCENE_ITEM_TRANSFORM = 'SetSceneItemTransform',
373377
SET_MUTE = 'SetInputMute',
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { OBSCommandWithContextTyped } from '..'
2+
import type { OBSDeviceState } from '../state'
3+
import { literal } from '../../../lib'
4+
import { DSK_VENDOR_NAME } from './types'
5+
import type { OBSDownstreamKeyerCache } from './types'
6+
7+
export function diffDownstreamKeyer(
8+
oldState: OBSDeviceState,
9+
newState: OBSDeviceState,
10+
cache?: OBSDownstreamKeyerCache
11+
): Array<OBSCommandWithContextTyped<'CallVendorRequest'>> {
12+
const commands: Array<OBSCommandWithContextTyped<'CallVendorRequest'>> = []
13+
14+
const desiredScene = newState.dsk?.selectedScene
15+
if (!desiredScene) return commands
16+
17+
const oldScene = oldState.dsk?.selectedScene
18+
if (oldScene === desiredScene) return commands
19+
20+
const ensureInDskList = newState.dsk?.ensureInDskList ?? true
21+
const shouldAdd = ensureInDskList && (!cache || !cache.scenes.has(desiredScene))
22+
23+
if (shouldAdd) {
24+
commands.push(
25+
literal<OBSCommandWithContextTyped<'CallVendorRequest'>>({
26+
command: {
27+
requestName: 'CallVendorRequest',
28+
args: {
29+
vendorName: DSK_VENDOR_NAME,
30+
requestType: 'dsk_add_scene',
31+
requestData: { scene: desiredScene },
32+
},
33+
},
34+
context: `dsk add scene "${desiredScene}"`,
35+
timelineObjId: '',
36+
})
37+
)
38+
}
39+
40+
commands.push(
41+
literal<OBSCommandWithContextTyped<'CallVendorRequest'>>({
42+
command: {
43+
requestName: 'CallVendorRequest',
44+
args: {
45+
vendorName: DSK_VENDOR_NAME,
46+
requestType: 'dsk_select_scene',
47+
requestData: { scene: desiredScene },
48+
},
49+
},
50+
context: `dsk select scene changed from "${oldScene}" to "${desiredScene}"`,
51+
timelineObjId: '',
52+
})
53+
)
54+
55+
return commands
56+
}
57+
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { OBSResponseTypes } from 'obs-websocket-js'
2+
import type { OBSDownstreamKeyerCache } from './types'
3+
import { DSK_VENDOR_NAME } from './types'
4+
5+
function toStringArray(value: unknown): string[] | undefined {
6+
if (!Array.isArray(value)) return undefined
7+
const out: string[] = []
8+
for (const v of value) {
9+
if (typeof v === 'string') out.push(v)
10+
}
11+
return out.length ? out : undefined
12+
}
13+
14+
function extractScenesFromResponseData(responseData: unknown): string[] {
15+
if (!responseData || typeof responseData !== 'object') return []
16+
const data = responseData as any
17+
18+
// Common shapes:
19+
// - { scenes: ["A","B"] }
20+
// - { downstream_keyers: [ { scenes: [...] }, ... ] }
21+
// - { downstreamKeyers: [ { scenes: [...] }, ... ] }
22+
// - { downstream_keyer: { scenes: [...] } }
23+
// - { downstreamKeyer: { scenes: [...] } }
24+
25+
const direct = toStringArray(data.scenes)
26+
if (direct) return direct
27+
28+
const keyers = data.downstream_keyers ?? data.downstreamKeyers
29+
if (Array.isArray(keyers)) {
30+
const scenes: string[] = []
31+
for (const k of keyers) {
32+
const s = toStringArray(k?.scenes)
33+
if (s) scenes.push(...s)
34+
}
35+
return scenes
36+
}
37+
38+
const single = data.downstream_keyer ?? data.downstreamKeyer
39+
const singleScenes = toStringArray(single?.scenes)
40+
if (singleScenes) return singleScenes
41+
42+
return []
43+
}
44+
45+
export async function refreshDownstreamKeyerCache(
46+
call: <Type extends 'CallVendorRequest'>(
47+
requestType: Type,
48+
requestData: OBSRequestTypesForCallVendorRequest
49+
) => Promise<OBSResponseTypes[Type]>
50+
): Promise<OBSDownstreamKeyerCache | undefined> {
51+
// Try the most general first:
52+
const tryRequest = async (requestType: string): Promise<unknown> => {
53+
const res = await call('CallVendorRequest', {
54+
vendorName: DSK_VENDOR_NAME,
55+
requestType,
56+
requestData: {},
57+
} as any)
58+
return (res as any)?.responseData
59+
}
60+
61+
try {
62+
const data = await tryRequest('get_downstream_keyers')
63+
const scenes = extractScenesFromResponseData(data)
64+
return { scenes: new Set(scenes) }
65+
} catch (e0) {
66+
try {
67+
const data = await tryRequest('get_downstream_keyer')
68+
const scenes = extractScenesFromResponseData(data)
69+
return { scenes: new Set(scenes) }
70+
} catch (e1) {
71+
return undefined
72+
}
73+
}
74+
}
75+
76+
// Local helper type: keep this file independent of obs-websocket-js internals
77+
type OBSRequestTypesForCallVendorRequest = {
78+
vendorName: string
79+
requestType: string
80+
requestData?: unknown
81+
}
82+
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { OBSDeviceState } from '../state'
2+
3+
export function applyDownstreamKeyerTimelineObject(
4+
deviceState: OBSDeviceState,
5+
mapping: { mappingType: string },
6+
content: { type: string; [key: string]: any }
7+
): void {
8+
if (mapping.mappingType !== 'downstreamKeyer') return
9+
if (content.type !== 'DOWNSTREAM_KEYER') return
10+
11+
if (!deviceState.dsk) deviceState.dsk = {}
12+
deviceState.dsk.selectedScene = content.sceneName
13+
deviceState.dsk.ensureInDskList = content.ensureInDskList
14+
}
15+
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export interface OBSDownstreamKeyerState {
2+
selectedScene?: string
3+
ensureInDskList?: boolean
4+
}
5+
6+
export interface OBSDownstreamKeyerCache {
7+
/** Scenes known to exist in the plugin's list */
8+
scenes: ReadonlySet<string>
9+
}
10+
11+
export const DSK_VENDOR_NAME = 'downstream-keyer' as const
12+
13+
export type DskVendorRequestType =
14+
| 'get_downstream_keyers'
15+
| 'get_downstream_keyer'
16+
| 'dsk_select_scene'
17+
| 'dsk_add_scene'
18+
19+
export interface DskSelectSceneRequest {
20+
scene: string
21+
}
22+
export interface DskAddSceneRequest {
23+
scene: string
24+
}
25+

0 commit comments

Comments
 (0)