diff --git a/packages/timeline-state-resolver-api/src/device.ts b/packages/timeline-state-resolver-api/src/device.ts index 2f276c852a..5344e20d26 100644 --- a/packages/timeline-state-resolver-api/src/device.ts +++ b/packages/timeline-state-resolver-api/src/device.ts @@ -247,6 +247,14 @@ export interface DeviceContextAPI { /** Reset the tracked device state to "state" and notify the conductor to reset the resolver */ resetToState: (state: DeviceState) => Promise + /** + * Modify the tracked device state and notify the conductor to reset the resolver + * @param cb A callback that receives the current state, and should return the modified state. + * Note: The `currentState` argument is a clone, so it is safe to modify it directly. + * If no changes have been made, return false. + */ + setModifiedState: (cb: (currentState: DeviceState) => DeviceState | false) => Promise + /** Calculate a new diff for the next state change */ recalcDiff: () => void diff --git a/packages/timeline-state-resolver-api/tsconfig.build.json b/packages/timeline-state-resolver-api/tsconfig.build.json index 24b690353e..fc9d997a9f 100644 --- a/packages/timeline-state-resolver-api/tsconfig.build.json +++ b/packages/timeline-state-resolver-api/tsconfig.build.json @@ -1,17 +1,36 @@ { "extends": "@sofie-automation/code-standard-preset/ts/tsconfig.lib", - "include": ["src/**/*.ts"], - "exclude": ["node_modules/**", "src/**/*spec.ts", "src/**/__tests__/*", "src/**/__mocks__/*"], + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "node_modules/**", + "src/**/*spec.ts", + "src/**/__tests__/*", + "src/**/__mocks__/*" + ], "compilerOptions": { "outDir": "./dist", "rootDir": "./src", "baseUrl": "./", "paths": { - "*": ["./node_modules/*"], - "{{PACKAGE-NAME}}": ["./src/index.ts"] + "*": [ + "./node_modules/*" + ], + "{{PACKAGE-NAME}}": [ + "./src/index.ts" + ] }, - "types": ["node"], + "types": [ + "node" + ], + "moduleResolution": "node16", + "esModuleInterop": true, "composite": true }, - "references": [{ "path": "../timeline-state-resolver-types/tsconfig.build.json" }] + "references": [ + { + "path": "../timeline-state-resolver-types/tsconfig.build.json" + } + ] } diff --git a/packages/timeline-state-resolver-types/.eslintrc.json b/packages/timeline-state-resolver-types/.eslintrc.json index 09e7844b62..65f7ffb39f 100644 --- a/packages/timeline-state-resolver-types/.eslintrc.json +++ b/packages/timeline-state-resolver-types/.eslintrc.json @@ -1,3 +1,15 @@ { - "extends": "../../node_modules/@sofie-automation/code-standard-preset/eslint/main" + "extends": "../../node_modules/@sofie-automation/code-standard-preset/eslint/main", + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "node/no-missing-import": ["error", { + // Temporary issue, likely because the `node` plugin is rather outdated and doesnt correctly handle `exports` field in package.json + // This will go away once the code-standard-preset is updated to a newer version + "allowModules": ["kairos-lib"] + }] + } + } + ] } diff --git a/packages/timeline-state-resolver-types/package.json b/packages/timeline-state-resolver-types/package.json index 435373fbad..061034ddca 100644 --- a/packages/timeline-state-resolver-types/package.json +++ b/packages/timeline-state-resolver-types/package.json @@ -81,6 +81,7 @@ "production" ], "dependencies": { + "kairos-lib": "0.2.3", "tslib": "^2.8.1" }, "publishConfig": { diff --git a/packages/timeline-state-resolver-types/src/__tests__/__snapshots__/index.spec.ts.snap b/packages/timeline-state-resolver-types/src/__tests__/__snapshots__/index.spec.ts.snap index 820f78e7eb..0cb13cbfbf 100644 --- a/packages/timeline-state-resolver-types/src/__tests__/__snapshots__/index.spec.ts.snap +++ b/packages/timeline-state-resolver-types/src/__tests__/__snapshots__/index.spec.ts.snap @@ -22,10 +22,13 @@ exports[`index imports 1`] = ` "HttpMethod", "HttpSendActions", "HyperdeckActions", + "KairosActions", + "KairosMacroActiveState", "LawoDeviceMode", "MappingAtemType", "MappingCasparCGType", "MappingHyperdeckType", + "MappingKairosType", "MappingLawoType", "MappingMultiOscType", "MappingObsType", @@ -57,6 +60,7 @@ exports[`index imports 1`] = ` "TimelineContentTypeHTTP", "TimelineContentTypeHTTPParamType", "TimelineContentTypeHyperdeck", + "TimelineContentTypeKairos", "TimelineContentTypeLawo", "TimelineContentTypeMultiOSC", "TimelineContentTypeOBS", diff --git a/packages/timeline-state-resolver-types/src/generated/device-options.ts b/packages/timeline-state-resolver-types/src/generated/device-options.ts index 9e69b218dd..afd6845e64 100644 --- a/packages/timeline-state-resolver-types/src/generated/device-options.ts +++ b/packages/timeline-state-resolver-types/src/generated/device-options.ts @@ -36,6 +36,11 @@ export interface DeviceOptionsHyperdeck extends DeviceOptionsBase { + type: DeviceType.KAIROS +} + import type { LawoOptions } from './lawo' export interface DeviceOptionsLawo extends DeviceOptionsBase { type: DeviceType.LAWO @@ -133,6 +138,7 @@ export type DeviceOptionsAny = | DeviceOptionsHttpSend | DeviceOptionsHttpWatcher | DeviceOptionsHyperdeck + | DeviceOptionsKairos | DeviceOptionsLawo | DeviceOptionsMultiOsc | DeviceOptionsObs @@ -165,6 +171,7 @@ export enum DeviceType { HTTPSEND = 'HTTPSEND', HTTPWATCHER = 'HTTPWATCHER', HYPERDECK = 'HYPERDECK', + KAIROS = 'KAIROS', LAWO = 'LAWO', MULTI_OSC = 'MULTI_OSC', OBS = 'OBS', diff --git a/packages/timeline-state-resolver-types/src/generated/index.ts b/packages/timeline-state-resolver-types/src/generated/index.ts index 258f08bf17..d7fb30f784 100644 --- a/packages/timeline-state-resolver-types/src/generated/index.ts +++ b/packages/timeline-state-resolver-types/src/generated/index.ts @@ -27,6 +27,9 @@ import type { SomeMappingHttpWatcher } from './httpWatcher' export * from './hyperdeck' import type { SomeMappingHyperdeck } from './hyperdeck' +export * from './kairos' +import type { SomeMappingKairos } from './kairos' + export * from './lawo' import type { SomeMappingLawo } from './lawo' @@ -88,6 +91,7 @@ export type TSRMappingOptions = | SomeMappingHttpSend | SomeMappingHttpWatcher | SomeMappingHyperdeck + | SomeMappingKairos | SomeMappingLawo | SomeMappingMultiOsc | SomeMappingObs diff --git a/packages/timeline-state-resolver-types/src/generated/kairos.ts b/packages/timeline-state-resolver-types/src/generated/kairos.ts new file mode 100644 index 0000000000..981c8069d3 --- /dev/null +++ b/packages/timeline-state-resolver-types/src/generated/kairos.ts @@ -0,0 +1,383 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and re-run the "tsr-schema-types" tool to regenerate this file. + */ +import type { ActionExecutionResult } from "../actions" + +export interface KairosOptions { + /** + * Host of KAIROS server + */ + host: string + /** + * Port of KAIROS server + */ + port?: number +} + +export interface MappingKairosScene { + sceneName: string[] + mappingType: MappingKairosType.Scene +} + +export interface MappingKairosSceneLayer { + sceneName: string[] + layerName: string[] + mappingType: MappingKairosType.SceneLayer +} + +export interface MappingKairosAux { + auxName: string + mappingType: MappingKairosType.Aux +} + +export interface MappingKairosMacro { + mappingType: MappingKairosType.Macro +} + +export interface MappingKairosClipPlayer { + playerId: number + framerate?: number + clearPlayerOnStop?: boolean + mappingType: MappingKairosType.ClipPlayer +} + +export interface MappingKairosRamRecPlayer { + playerId: number + framerate?: number + clearPlayerOnStop?: boolean + mappingType: MappingKairosType.RamRecPlayer +} + +export interface MappingKairosImageStore { + playerId: number + clearPlayerOnStop?: boolean + mappingType: MappingKairosType.ImageStore +} + +export interface MappingKairosSoundPlayer { + playerId: number + framerate?: number + clearPlayerOnStop?: boolean + mappingType: MappingKairosType.SoundPlayer +} + +export enum MappingKairosType { + Scene = 'scene', + SceneLayer = 'scene-layer', + Aux = 'aux', + Macro = 'macro', + ClipPlayer = 'clip-player', + RamRecPlayer = 'ram-rec-player', + ImageStore = 'image-store', + SoundPlayer = 'sound-player', +} + +export type SomeMappingKairos = MappingKairosScene | MappingKairosSceneLayer | MappingKairosAux | MappingKairosMacro | MappingKairosClipPlayer | MappingKairosRamRecPlayer | MappingKairosImageStore | MappingKairosSoundPlayer + +export interface ListScenesPayload { + scenePath: string[] + deep?: boolean +} + +export type ListScenesResult = ({ + name: string +} & SceneRef)[] + +export interface SceneRef { + realm: 'scene' + scenePath: string[] +} + +export interface ListSceneLayersPayload { + scenePath: string[] + layerPath: string[] + deep?: boolean +} + +export type ListSceneLayersResult = ({ + name: string +} & SceneLayerRef)[] + +export interface SceneLayerRef { + realm: 'scene-layer' + scenePath: string[] + layerPath: string[] +} + +export interface ListSceneLayerEffectsPayload { + scenePath: string[] + layerPath: string[] + deep?: boolean +} + +export type ListSceneLayerEffectsResult = ({ + name: string +} & SceneLayerEffectRef)[] + +export interface SceneLayerEffectRef { + realm: 'scene-layer-effect' + scenePath: string[] + layerPath: string[] + effectPath: string[] +} + +export interface ListSceneTransitionsPayload { + scenePath: string[] +} + +export type ListSceneTransitionsResult = SceneTransitionRef[] + +export interface SceneTransitionRef { + realm: 'scene-transition' + scenePath: string[] + transitionPath: unknown[] +} + +export interface ListSceneSnapshotsPayload { + scenePath: string[] +} + +export type ListSceneSnapshotsResult = ({ + name: string +} & SceneSnapshotRef)[] + +export interface SceneSnapshotRef { + realm: 'scene-snapshot' + scenePath: string[] + snapshotPath: string[] +} + +export interface ListFxInputsPayload {} + +export type ListFxInputsResult = ({ + name: string +} & FxInputRef)[] + +export interface FxInputRef { + realm: 'fxInput' + fxInputPath: string[] +} + +export interface ListMattesPayload {} + +export type ListMattesResult = ({ + name: string +} & MatteRef)[] + +export interface MatteRef { + realm: 'matte' + mattePath: string[] +} + +export interface ListMediaClipsPayload {} + +export type ListMediaClipsResult = ({ + name: string +} & MediaClipRef)[] + +export interface MediaClipRef { + realm: 'media-clip' + clipPath: string[] +} + +export interface ListMediaStillsPayload {} + +export type ListMediaStillsResult = ({ + name: string +} & MediaStillRef)[] + +export interface MediaStillRef { + realm: 'media-still' + clipPath: string[] +} + +export interface ListMediaRamRecPayload {} + +export type ListMediaRamRecResult = ({ + name: string +} & MediaRamRecRef)[] + +export interface MediaRamRecRef { + realm: 'media-ramrec' + clipPath: string[] +} + +export interface ListMediaImagePayload {} + +export type ListMediaImageResult = ({ + name: string +} & MediaImageRef)[] + +export interface MediaImageRef { + realm: 'media-image' + clipPath: string[] +} + +export interface ListMediaSoundsPayload {} + +export type ListMediaSoundsResult = ({ + name: string +} & MediaSoundRef)[] + +export interface MediaSoundRef { + realm: 'media-sound' + clipPath: string[] +} + +export interface ListMacrosPayload { + macroPath?: string[] + deep?: boolean +} + +export type ListMacrosResult = ({ + name: string +} & MacroRef)[] + +export interface MacroRef { + realm: 'macro' + macroPath: string[] +} + +export interface ListAuxesPayload {} + +export type ListAuxesResult = AuxRef[] + +export interface AuxRef { + realm: 'aux' + path: string + /** + * true if the path is a name, false if it is an id + */ + pathIsName: boolean +} + +export interface ListAuxEffectsPayload { + path: string + /** + * true if the path is a name, false if it is an id + */ + pathIsName: boolean + deep?: boolean +} + +export type ListAuxEffectsResult = ({ + name: string +} & AuxEffectRef)[] + +export interface AuxEffectRef { + realm: 'aux-effect' + auxPath: string + /** + * true if the path is a name, false if it is an id + */ + auxPathIsName: boolean + effectPath: string[] +} + +export interface ListGfxScenesPayload { + scenePath?: string[] + deep?: boolean +} + +export type ListGfxScenesResult = ({ + name: string +} & GfxSceneRef)[] + +export interface GfxSceneRef { + realm: 'gfxScene' + scenePath: string[] +} + +export interface ListGfxSceneItemsPayload { + scenePath: string[] +} + +export type ListGfxSceneItemsResult = ({ + name: string +} & GfxSceneItemRef)[] + +export interface GfxSceneItemRef { + realm: 'gfxScene-item' + scenePath: string[] + sceneItemPath: string[] +} + +export interface ListAudioMixerChannelsPayload {} + +export type ListAudioMixerChannelsResult = ({ + name: string +} & AudioMixerChannelRef)[] + +export interface AudioMixerChannelRef { + realm: 'audioMixer-channel' + channelPath: string[] +} + +export interface MacroPlayPayload { + macroPath: string[] +} + +export interface MacroStopPayload { + macroPath: string[] +} + +export interface SceneSnapshotRecallPayload { + scenePath: string[] + snapshotPath: string[] +} + +export enum KairosActions { + ListScenes = 'listScenes', + ListSceneLayers = 'listSceneLayers', + ListSceneLayerEffects = 'listSceneLayerEffects', + ListSceneTransitions = 'listSceneTransitions', + ListSceneSnapshots = 'listSceneSnapshots', + ListFxInputs = 'listFxInputs', + ListMattes = 'listMattes', + ListMediaClips = 'listMediaClips', + ListMediaStills = 'listMediaStills', + ListMediaRamRec = 'listMediaRamRec', + ListMediaImage = 'listMediaImage', + ListMediaSounds = 'listMediaSounds', + ListMacros = 'listMacros', + ListAuxes = 'listAuxes', + ListAuxEffects = 'listAuxEffects', + ListGfxScenes = 'listGfxScenes', + ListGfxSceneItems = 'listGfxSceneItems', + ListAudioMixerChannels = 'listAudioMixerChannels', + MacroPlay = 'macroPlay', + MacroStop = 'macroStop', + SceneSnapshotRecall = 'sceneSnapshotRecall' +} +export interface KairosActionMethods { + [KairosActions.ListScenes]: (payload: ListScenesPayload) => Promise>, + [KairosActions.ListSceneLayers]: (payload: ListSceneLayersPayload) => Promise>, + [KairosActions.ListSceneLayerEffects]: (payload: ListSceneLayerEffectsPayload) => Promise>, + [KairosActions.ListSceneTransitions]: (payload: ListSceneTransitionsPayload) => Promise>, + [KairosActions.ListSceneSnapshots]: (payload: ListSceneSnapshotsPayload) => Promise>, + [KairosActions.ListFxInputs]: (payload: ListFxInputsPayload) => Promise>, + [KairosActions.ListMattes]: (payload: ListMattesPayload) => Promise>, + [KairosActions.ListMediaClips]: (payload: ListMediaClipsPayload) => Promise>, + [KairosActions.ListMediaStills]: (payload: ListMediaStillsPayload) => Promise>, + [KairosActions.ListMediaRamRec]: (payload: ListMediaRamRecPayload) => Promise>, + [KairosActions.ListMediaImage]: (payload: ListMediaImagePayload) => Promise>, + [KairosActions.ListMediaSounds]: (payload: ListMediaSoundsPayload) => Promise>, + [KairosActions.ListMacros]: (payload: ListMacrosPayload) => Promise>, + [KairosActions.ListAuxes]: (payload: ListAuxesPayload) => Promise>, + [KairosActions.ListAuxEffects]: (payload: ListAuxEffectsPayload) => Promise>, + [KairosActions.ListGfxScenes]: (payload: ListGfxScenesPayload) => Promise>, + [KairosActions.ListGfxSceneItems]: (payload: ListGfxSceneItemsPayload) => Promise>, + [KairosActions.ListAudioMixerChannels]: (payload: ListAudioMixerChannelsPayload) => Promise>, + [KairosActions.MacroPlay]: (payload: MacroPlayPayload) => Promise>, + [KairosActions.MacroStop]: (payload: MacroStopPayload) => Promise>, + [KairosActions.SceneSnapshotRecall]: (payload: SceneSnapshotRecallPayload) => Promise> +} + +export interface KairosDeviceTypes { + Options: KairosOptions + Mappings: SomeMappingKairos + Actions: KairosActionMethods +} diff --git a/packages/timeline-state-resolver-types/src/index.ts b/packages/timeline-state-resolver-types/src/index.ts index c9888aad3e..4169cd864d 100644 --- a/packages/timeline-state-resolver-types/src/index.ts +++ b/packages/timeline-state-resolver-types/src/index.ts @@ -23,6 +23,7 @@ import { TimelineContentVMixAny } from './integrations/vmix' import { TimelineContentOBSAny } from './integrations/obs' import { TimelineContentTriCasterAny } from './integrations/tricaster' import { TimelineContentWebSocketClientAny } from './integrations/websocketClient' +import { TimelineContentKairosAny } from './integrations/kairos' import { DeviceType } from './generated' export * from './integrations/abstract' @@ -30,6 +31,7 @@ export * from './integrations/atem' export * from './integrations/casparcg' export * from './integrations/httpSend' export * from './integrations/hyperdeck' +export * from './integrations/kairos' export * from './integrations/lawo' export * from './integrations/osc' export * from './integrations/pharos' @@ -109,6 +111,7 @@ export interface TimelineContentMap { [DeviceType.HTTPSEND]: TimelineContentHTTPSendAny [DeviceType.TCPSEND]: TimelineContentTCPSendAny [DeviceType.HYPERDECK]: TimelineContentHyperdeckAny + [DeviceType.KAIROS]: TimelineContentKairosAny [DeviceType.LAWO]: TimelineContentLawoAny [DeviceType.OBS]: TimelineContentOBSAny [DeviceType.OSC]: TimelineContentOSCAny diff --git a/packages/timeline-state-resolver-types/src/integrations/kairos.ts b/packages/timeline-state-resolver-types/src/integrations/kairos.ts new file mode 100644 index 0000000000..78d87f7c55 --- /dev/null +++ b/packages/timeline-state-resolver-types/src/integrations/kairos.ts @@ -0,0 +1,206 @@ +import type { DeviceType } from '..' +import type { + RefPath, + MediaClipRef, + UpdateSceneObject, + UpdateSceneLayerObject, + UpdateClipPlayerObject, + MediaRamRecRef, + MediaSoundRef, + UpdateSceneSnapshotObject, + UpdateAuxObject, + MediaStillRef, + MediaImageRef, + DissolveMode, + UpdateImageStoreObject, +} from 'kairos-lib' + +export enum TimelineContentTypeKairos { + SCENE = 'scene', + SCENE_LAYER = 'scene-layer', + + // MVs? - no + // gfx / painter - yes, to be implemented + + CLIP_PLAYER = 'clip-player', + RAMREC_PLAYER = 'ramrec-player', + IMAGE_STORE = 'image-store', + SOUND_PLAYER = 'sound-player', + + AUX = 'aux', + MACROS = 'macros', +} + +/* +const DELETE_IT = 'delete-it-plz-actually-not-plz-just-do-it' as const +type PartialOrNull = { + [P in keyof T]?: T[P] | typeof DELETE_IT; +}; +*/ + +export type TimelineContentKairosAny = + | TimelineContentKairosScene + | TimelineContentKairosSceneLayer + | TimelineContentKairosAux + | TimelineContentKairosMacros + | TimelineContentKairosClipPlayer + | TimelineContentKairosRamRecPlayer + | TimelineContentKairosImageStore + | TimelineContentKairosSoundPlayer + +export interface TimelineContentKairosScene { + deviceType: DeviceType.KAIROS + type: TimelineContentTypeKairos.SCENE + + scene: Partial + + recallSnapshots: { + // The snapshotName MUST be based on the ref (it's not really used in TSR, but should be unique) + [snapshotName: string]: TimelineContentKairosSceneSnapshotInfo + } +} + +export interface TimelineContentKairosSceneSnapshotInfo { + /** Reference to the Snapshot */ + ref: RefPath + /** When this is true, the Snapshot will be recalled */ + active: boolean + + properties: Partial +} + +export interface TimelineContentKairosSceneLayer { + deviceType: DeviceType.KAIROS + type: TimelineContentTypeKairos.SCENE_LAYER + + sceneLayer: Partial +} + +export interface TimelineContentKairosAux { + deviceType: DeviceType.KAIROS + type: TimelineContentTypeKairos.AUX + + aux: Partial +} + +export interface TimelineContentKairosMacros { + deviceType: DeviceType.KAIROS + type: TimelineContentTypeKairos.MACROS + + macros: { + // The macroName MUST be based on the ref (it's not really used in TSR, but should be unique) + [macroName: string]: TimelineContentKairosMacroInfo + } +} +export interface TimelineContentKairosMacroInfo { + ref: RefPath + active: KairosMacroActiveState +} +export enum KairosMacroActiveState { + /** The Macro will be played */ + PLAYING = 'playing', + /** The Macro will be stopped */ + STOPPED = 'stopped', + /** No command will be sent */ + UNCHANGED = 'unchanged', +} + +export interface TimelineContentKairosClipPlayer { + deviceType: DeviceType.KAIROS + type: TimelineContentTypeKairos.CLIP_PLAYER + + clipPlayer: TimelineContentKairosPlayerState +} +export interface TimelineContentKairosRamRecPlayer { + deviceType: DeviceType.KAIROS + type: TimelineContentTypeKairos.RAMREC_PLAYER + + ramRecPlayer: TimelineContentKairosPlayerState +} +export interface TimelineContentKairosImageStore { + deviceType: DeviceType.KAIROS + type: TimelineContentTypeKairos.IMAGE_STORE + + imageStore: Partial< + Pick< + UpdateImageStoreObject, + | 'colorOverwrite' + | 'color' + | 'removeSourceAlpha' + | 'scaleMode' + | 'resolution' + | 'advancedResolutionControl' + | 'resolutionX' + | 'resolutionY' + > + > & { + /** + * Reference to the file to be played + * @example "MEDIA.clips.amb.mxf" + */ + clip: MediaStillRef | MediaImageRef | null + + /** + * If set, defines if the player should Clear (to black) when the still is stopped + * (ie the timeline object ends), or just leave it. + * Defaults to the clearPlayerOnStop property of the Mapping, or false + */ + clearPlayerOnStop?: boolean + + dissolve?: { + enabled?: boolean + + duration: number + mode: DissolveMode + } + } +} +export interface TimelineContentKairosSoundPlayer { + deviceType: DeviceType.KAIROS + type: TimelineContentTypeKairos.SOUND_PLAYER + + soundPlayer: TimelineContentKairosPlayerState +} + +// Note: This is quite inspired from the CasparCG Media type: +export interface TimelineContentKairosPlayerState + extends Partial> { + // clip player / ramrec player + + /** + * Reference to the file to be played + * @example "MEDIA.clips.amb.mxf" + */ + clip?: TClip + + /** + * Whether the media file should be looping or not. + * If this is true, the actual frame position is not guaranteed (ie seek is ignored). + */ + repeat?: boolean + + /** + * The point where the file starts playing [milliseconds from start of file] + * If undefined, this indicates that it doesn't matter where the file starts playing. + * To ensure that a file starts from the beginning, set seek to 0. + * */ + seek?: number + + /** When pausing, the unix-time the playout was paused. */ + // pauseTime?: number + + /** If the video is playing or is paused (defaults to true) */ + playing?: boolean + + /** + * If set, defines if the player should Clear (to black) when the clip is stopped, + * or just Pause. + * Defaults to the clearPlayerOnStop property of the Mapping, or false + */ + clearPlayerOnStop?: boolean + + // reverse?: boolean + + /** If true, the startTime won't be used to SEEK to the correct place in the media */ + // noStarttime?: boolean +} diff --git a/packages/timeline-state-resolver-types/tsconfig.build.json b/packages/timeline-state-resolver-types/tsconfig.build.json index 4e9fbe0bc8..b04d3be8ae 100644 --- a/packages/timeline-state-resolver-types/tsconfig.build.json +++ b/packages/timeline-state-resolver-types/tsconfig.build.json @@ -1,16 +1,31 @@ { "extends": "@sofie-automation/code-standard-preset/ts/tsconfig.lib", - "include": ["src/**/*.ts"], - "exclude": ["node_modules/**", "src/**/*spec.ts", "src/**/__tests__/*", "src/**/__mocks__/*"], + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "node_modules/**", + "src/**/*spec.ts", + "src/**/__tests__/*", + "src/**/__mocks__/*" + ], "compilerOptions": { "outDir": "./dist", "rootDir": "./src", "baseUrl": "./", "paths": { - "*": ["./node_modules/*"], - "{{PACKAGE-NAME}}": ["./src/index.ts"] + "*": [ + "./node_modules/*" + ], + "{{PACKAGE-NAME}}": [ + "./src/index.ts" + ] }, - "types": ["node"], + "types": [ + "node" + ], + "moduleResolution": "node16", + "esModuleInterop": true, "composite": true } } diff --git a/packages/timeline-state-resolver/.eslintrc.json b/packages/timeline-state-resolver/.eslintrc.json index 0aa08fae07..dc6b070a4c 100644 --- a/packages/timeline-state-resolver/.eslintrc.json +++ b/packages/timeline-state-resolver/.eslintrc.json @@ -9,7 +9,13 @@ "@typescript-eslint/no-non-null-assertion": 0, "@typescript-eslint/ban-types": 0, "@typescript-eslint/ban-ts-comment": 0, - "@typescript-eslint/no-namespace": 0 + "@typescript-eslint/no-namespace": 0, + + "node/no-missing-import": ["error", { + // Temporary issue, likely because the `node` plugin is rather outdated and doesnt correctly handle `exports` field in package.json + // This will go away once the code-standard-preset is updated to a newer version + "allowModules": ["kairos-connection"] + }] } }, { diff --git a/packages/timeline-state-resolver/package.json b/packages/timeline-state-resolver/package.json index d933e723e4..6077e01bfd 100644 --- a/packages/timeline-state-resolver/package.json +++ b/packages/timeline-state-resolver/package.json @@ -100,6 +100,7 @@ "got": "^11.8.6", "hpagent": "^1.2.0", "hyperdeck-connection": "2.0.1", + "kairos-connection": "0.2.3", "klona": "^2.0.6", "obs-websocket-js": "^5.0.7", "osc": "^2.4.5", diff --git a/packages/timeline-state-resolver/src/__mocks__/ws.ts b/packages/timeline-state-resolver/src/__mocks__/ws.ts index a64ce57abe..4a140f96d9 100644 --- a/packages/timeline-state-resolver/src/__mocks__/ws.ts +++ b/packages/timeline-state-resolver/src/__mocks__/ws.ts @@ -132,4 +132,4 @@ class WebSocket extends EventEmitter { } } namespace WebSocket {} -export = WebSocket +export default WebSocket diff --git a/packages/timeline-state-resolver/src/__tests__/__snapshots__/index.spec.ts.snap b/packages/timeline-state-resolver/src/__tests__/__snapshots__/index.spec.ts.snap index 9afb1dc0ae..2e566b09f0 100644 --- a/packages/timeline-state-resolver/src/__tests__/__snapshots__/index.spec.ts.snap +++ b/packages/timeline-state-resolver/src/__tests__/__snapshots__/index.spec.ts.snap @@ -30,6 +30,8 @@ exports[`index imports 1`] = ` "HttpSendActions", "HyperdeckActions", "HyperdeckDevice", + "KairosActions", + "KairosMacroActiveState", "LOOKAHEADTIME", "LawoDeviceMode", "MINTIMEUNIT", @@ -37,6 +39,7 @@ exports[`index imports 1`] = ` "MappingAtemType", "MappingCasparCGType", "MappingHyperdeckType", + "MappingKairosType", "MappingLawoType", "MappingMultiOscType", "MappingObsType", @@ -71,6 +74,7 @@ exports[`index imports 1`] = ` "TimelineContentTypeHTTP", "TimelineContentTypeHTTPParamType", "TimelineContentTypeHyperdeck", + "TimelineContentTypeKairos", "TimelineContentTypeLawo", "TimelineContentTypeMultiOSC", "TimelineContentTypeOBS", diff --git a/packages/timeline-state-resolver/src/conductor.ts b/packages/timeline-state-resolver/src/conductor.ts index 2f26cfe5be..3cdff092ac 100644 --- a/packages/timeline-state-resolver/src/conductor.ts +++ b/packages/timeline-state-resolver/src/conductor.ts @@ -9,7 +9,7 @@ import { import { EventEmitter } from 'node:events' import { MemUsageReport, threadedClass, ThreadedClass, ThreadedClassManager } from 'threadedclass' import PQueue from 'p-queue' -import * as PAll from 'p-all' +import PAll from 'p-all' import { Mappings, diff --git a/packages/timeline-state-resolver/src/integrations/__tests__/testlib.ts b/packages/timeline-state-resolver/src/integrations/__tests__/testlib.ts index 1bd19c57ed..c4e20bc72f 100644 --- a/packages/timeline-state-resolver/src/integrations/__tests__/testlib.ts +++ b/packages/timeline-state-resolver/src/integrations/__tests__/testlib.ts @@ -20,6 +20,7 @@ export function getDeviceContext(): DeviceContextAPI { timeTrace: jest.fn(), resetState: jest.fn(async () => Promise.resolve()), resetToState: jest.fn(async () => Promise.resolve()), + setModifiedState: jest.fn(), recalcDiff: jest.fn(), setAddressState: jest.fn(), } diff --git a/packages/timeline-state-resolver/src/integrations/httpSend/__tests__/httpsend.spec.ts b/packages/timeline-state-resolver/src/integrations/httpSend/__tests__/httpsend.spec.ts index 86fd84c03f..556c156bdd 100644 --- a/packages/timeline-state-resolver/src/integrations/httpSend/__tests__/httpsend.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/httpSend/__tests__/httpsend.spec.ts @@ -14,12 +14,10 @@ const MOCKED_SOCKET_DELETE = jest.fn() jest.mock('got', () => { return { - default: { - get: MOCKED_SOCKET_GET, - post: MOCKED_SOCKET_POST, - put: MOCKED_SOCKET_PUT, - delete: MOCKED_SOCKET_DELETE, - }, + get: MOCKED_SOCKET_GET, + post: MOCKED_SOCKET_POST, + put: MOCKED_SOCKET_PUT, + delete: MOCKED_SOCKET_DELETE, } }) diff --git a/packages/timeline-state-resolver/src/integrations/httpWatcher/__tests__/httpwatcher.spec.ts b/packages/timeline-state-resolver/src/integrations/httpWatcher/__tests__/httpwatcher.spec.ts index c530cc3173..cf18a1e953 100644 --- a/packages/timeline-state-resolver/src/integrations/httpWatcher/__tests__/httpwatcher.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/httpWatcher/__tests__/httpwatcher.spec.ts @@ -8,12 +8,10 @@ const MOCKED_SOCKET_DELETE = jest.fn() jest.mock('got', () => { return { - default: { - get: MOCKED_SOCKET_GET, - post: MOCKED_SOCKET_POST, - put: MOCKED_SOCKET_PUT, - delete: MOCKED_SOCKET_DELETE, - }, + get: MOCKED_SOCKET_GET, + post: MOCKED_SOCKET_POST, + put: MOCKED_SOCKET_PUT, + delete: MOCKED_SOCKET_DELETE, } }) diff --git a/packages/timeline-state-resolver/src/integrations/kairos/$schemas/actions.json b/packages/timeline-state-resolver/src/integrations/kairos/$schemas/actions.json new file mode 100644 index 0000000000..987c4313a1 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/$schemas/actions.json @@ -0,0 +1,634 @@ +{ + "$schema": "../../../../../timeline-state-resolver-api/$schemas/action-schema.json", + "actions": [ + { + "id": "listScenes", + "name": "List Scenes", + "destructive": false, + "payload": { + "type": "object", + "properties": { + "scenePath": { + "$ref": "./lib/refs.json#/SceneRef/properties/scenePath" + }, + "deep": { + "type": "boolean" + } + }, + "required": ["scenePath"], + "additionalProperties": false + }, + "result": { + "type": "array", + "items": { + "type": "object", + "allOf": [ + { + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + }, + { + "$ref": "./lib/refs.json#/SceneRef" + } + ] + } + } + }, + { + "id": "listSceneLayers", + "name": "List Scene Layers", + "destructive": false, + "payload": { + "type": "object", + "properties": { + "scenePath": { + "$ref": "./lib/refs.json#/SceneLayerRef/properties/scenePath" + }, + "layerPath": { + "$ref": "./lib/refs.json#/SceneLayerRef/properties/layerPath" + }, + "deep": { + "type": "boolean" + } + }, + "required": ["scenePath", "layerPath"], + "additionalProperties": false + }, + "result": { + "type": "array", + "items": { + "type": "object", + "allOf": [ + { + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + }, + { + "$ref": "./lib/refs.json#/SceneLayerRef" + } + ] + } + } + }, + { + "id": "listSceneLayerEffects", + "name": "List Scene Layer Effects", + "destructive": false, + "payload": { + "type": "object", + "properties": { + "scenePath": { + "$ref": "./lib/refs.json#/SceneLayerRef/properties/scenePath" + }, + "layerPath": { + "$ref": "./lib/refs.json#/SceneLayerRef/properties/layerPath" + }, + "deep": { + "type": "boolean" + } + }, + "required": ["scenePath", "layerPath"], + "additionalProperties": false + }, + "result": { + "type": "array", + "items": { + "type": "object", + "allOf": [ + { + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + }, + { + "$ref": "./lib/refs.json#/SceneLayerEffectRef" + } + ] + } + } + }, + { + "id": "listSceneTransitions", + "name": "List Scene Transitions", + "destructive": false, + "payload": { + "type": "object", + "properties": { + "scenePath": { + "$ref": "./lib/refs.json#/SceneRef/properties/scenePath" + } + }, + "required": ["scenePath"], + "additionalProperties": false + }, + "result": { + "type": "array", + "items": { + "$ref": "./lib/refs.json#/SceneTransitionRef" + } + } + }, + { + "id": "listSceneSnapshots", + "name": "List Scene Snapshots", + "destructive": false, + "payload": { + "type": "object", + "properties": { + "scenePath": { + "$ref": "./lib/refs.json#/SceneRef/properties/scenePath" + } + }, + "required": ["scenePath"], + "additionalProperties": false + }, + "result": { + "type": "array", + "items": { + "type": "object", + "allOf": [ + { + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + }, + { + "$ref": "./lib/refs.json#/SceneSnapshotRef" + } + ] + } + } + }, + { + "id": "listFxInputs", + "name": "List FX Inputs", + "destructive": false, + "payload": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "result": { + "type": "array", + "items": { + "type": "object", + "allOf": [ + { + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + }, + { + "$ref": "./lib/refs.json#/FxInputRef" + } + ] + } + } + }, + { + "id": "listMattes", + "name": "List Mattes", + "destructive": false, + "payload": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "result": { + "type": "array", + "items": { + "type": "object", + "allOf": [ + { + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + }, + { + "$ref": "./lib/refs.json#/MatteRef" + } + ] + } + } + }, + { + "id": "listMediaClips", + "name": "List Media Clips", + "destructive": false, + "payload": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "result": { + "type": "array", + "items": { + "type": "object", + "allOf": [ + { + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + }, + { + "$ref": "./lib/refs.json#/MediaClipRef" + } + ] + } + } + }, + { + "id": "listMediaStills", + "name": "List Media Stills", + "destructive": false, + "payload": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "result": { + "type": "array", + "items": { + "type": "object", + "allOf": [ + { + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + }, + { + "$ref": "./lib/refs.json#/MediaStillRef" + } + ] + } + } + }, + { + "id": "listMediaRamRec", + "name": "List Media Ram Recordings", + "destructive": false, + "payload": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "result": { + "type": "array", + "items": { + "type": "object", + "allOf": [ + { + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + }, + { + "$ref": "./lib/refs.json#/MediaRamRecRef" + } + ] + } + } + }, + { + "id": "listMediaImage", + "name": "List Media Images", + "destructive": false, + "payload": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "result": { + "type": "array", + "items": { + "type": "object", + "allOf": [ + { + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + }, + { + "$ref": "./lib/refs.json#/MediaImageRef" + } + ] + } + } + }, + { + "id": "listMediaSounds", + "name": "List Media Sounds", + "destructive": false, + "payload": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "result": { + "type": "array", + "items": { + "type": "object", + "allOf": [ + { + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + }, + { + "$ref": "./lib/refs.json#/MediaSoundRef" + } + ] + } + } + }, + { + "id": "listMacros", + "name": "List Macros", + "destructive": false, + "payload": { + "type": "object", + "properties": { + "macroPath": { + "$ref": "./lib/refs.json#/MacroRef/properties/macroPath" + }, + "deep": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "result": { + "type": "array", + "items": { + "type": "object", + "allOf": [ + { + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + }, + { + "$ref": "./lib/refs.json#/MacroRef" + } + ] + } + } + }, + { + "id": "listAuxes", + "name": "List Auxes", + "destructive": false, + "payload": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "result": { + "type": "array", + "items": { + "$ref": "./lib/refs.json#/AuxRef" + } + } + }, + { + "id": "listAuxEffects", + "name": "List Aux Effects", + "destructive": false, + "payload": { + "type": "object", + "properties": { + "path": { + "$ref": "./lib/refs.json#/AuxRef/properties/path" + }, + "pathIsName": { + "$ref": "./lib/refs.json#/AuxRef/properties/pathIsName" + }, + "deep": { + "type": "boolean" + } + }, + "required": ["path", "pathIsName"], + "additionalProperties": false + }, + "result": { + "type": "array", + "items": { + "type": "object", + "allOf": [ + { + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + }, + { + "$ref": "./lib/refs.json#/AuxEffectRef" + } + ] + } + } + }, + { + "id": "listGfxScenes", + "name": "List GFX Scenes", + "destructive": false, + "payload": { + "type": "object", + "properties": { + "scenePath": { + "$ref": "./lib/refs.json#/GfxSceneRef/properties/scenePath" + }, + "deep": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "result": { + "type": "array", + "items": { + "type": "object", + "allOf": [ + { + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + }, + { + "$ref": "./lib/refs.json#/GfxSceneRef" + } + ] + } + } + }, + { + "id": "listGfxSceneItems", + "name": "List GFX Scene Items", + "destructive": false, + "payload": { + "type": "object", + "properties": { + "scenePath": { + "$ref": "./lib/refs.json#/GfxSceneRef/properties/scenePath" + } + }, + "required": ["scenePath"], + "additionalProperties": false + }, + "result": { + "type": "array", + "items": { + "type": "object", + "allOf": [ + { + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + }, + { + "$ref": "./lib/refs.json#/GfxSceneItemRef" + } + ] + } + } + }, + { + "id": "listAudioMixerChannels", + "name": "List Audio Mixer Channels", + "destructive": false, + "payload": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "result": { + "type": "array", + "items": { + "type": "object", + "allOf": [ + { + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + }, + { + "$ref": "./lib/refs.json#/AudioMixerChannelRef" + } + ] + } + } + }, + { + "id": "macroPlay", + "name": "Play a macro", + "destructive": false, + "payload": { + "type": "object", + "properties": { + "macroPath": { + "$ref": "./lib/refs.json#/MacroRef/properties/macroPath" + } + }, + "required": ["macroPath"], + "additionalProperties": false + } + }, + { + "id": "macroStop", + "name": "Stop a macro", + "destructive": false, + "payload": { + "type": "object", + "properties": { + "macroPath": { + "$ref": "./lib/refs.json#/MacroRef/properties/macroPath" + } + }, + "required": ["macroPath"], + "additionalProperties": false + } + }, + { + "id": "sceneSnapshotRecall", + "name": "Recall a Scene Snapshot", + "destructive": false, + "payload": { + "type": "object", + "properties": { + "scenePath": { + "$ref": "./lib/refs.json#/SceneSnapshotRef/properties/scenePath" + }, + "snapshotPath": { + "$ref": "./lib/refs.json#/SceneSnapshotRef/properties/snapshotPath" + } + }, + "required": ["scenePath", "snapshotPath"], + "additionalProperties": false + } + } + ] +} diff --git a/packages/timeline-state-resolver/src/integrations/kairos/$schemas/lib/refs.json b/packages/timeline-state-resolver/src/integrations/kairos/$schemas/lib/refs.json new file mode 100644 index 0000000000..4f57d85f98 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/$schemas/lib/refs.json @@ -0,0 +1,630 @@ +{ + "RefPath": { + "type": "array", + "items": { + "type": "string" + } + }, + "RefPathSingle": { + "type": "array", + "prefixItems": [ + { + "type": "string" + } + ] + }, + "SceneRef": { + "id": "SceneRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "scene" + }, + "scenePath": { + "$ref": "#/RefPath" + } + }, + "required": [ + "realm", + "scenePath" + ], + "additionalProperties": false + }, + "SceneLayerRef": { + "id": "SceneLayerRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "scene-layer" + }, + "scenePath": { + "$ref": "#/RefPath" + }, + "layerPath": { + "$ref": "#/RefPath" + } + }, + "required": [ + "realm", + "scenePath", + "layerPath" + ], + "additionalProperties": false + }, + "SceneLayerEffectRef": { + "id": "SceneLayerEffectRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "scene-layer-effect" + }, + "scenePath": { + "$ref": "#/RefPath" + }, + "layerPath": { + "$ref": "#/RefPath" + }, + "effectPath": { + "$ref": "#/RefPath" + } + }, + "required": [ + "realm", + "scenePath", + "layerPath", + "effectPath" + ], + "additionalProperties": false + }, + "SceneSnapshotRef": { + "id": "SceneSnapshotRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "scene-snapshot" + }, + "scenePath": { + "$ref": "#/RefPath" + }, + "snapshotPath": { + "$ref": "#/RefPath" + } + }, + "required": [ + "realm", + "scenePath", + "snapshotPath" + ], + "additionalProperties": false + }, + "SceneTransitionRef": { + "id": "SceneTransitionRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "scene-transition" + }, + "scenePath": { + "$ref": "#/RefPath" + }, + "transitionPath": { + "$ref": "#/RefPathSingle" + } + }, + "required": [ + "realm", + "scenePath", + "transitionPath" + ], + "additionalProperties": false + }, + "SceneTransitionMixRef": { + "id": "SceneTransitionMixRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "scene-transition-mix" + }, + "scenePath": { + "$ref": "#/RefPath" + }, + "transitionPath": { + "$ref": "#/RefPathSingle" + }, + "mixPath": { + "$ref": "#/RefPathSingle" + } + }, + "required": [ + "realm", + "scenePath", + "transitionPath", + "mixPath" + ], + "additionalProperties": false + }, + "SceneTransitionMixEffectRef": { + "id": "SceneTransitionMixEffectRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "scene-transition-mix-effect" + }, + "scenePath": { + "$ref": "#/RefPath" + }, + "transitionPath": { + "$ref": "#/RefPathSingle" + }, + "mixPath": { + "$ref": "#/RefPathSingle" + }, + "effectPath": { + "$ref": "#/RefPathSingle" + } + }, + "required": [ + "realm", + "scenePath", + "transitionPath", + "mixPath", + "effectPath" + ], + "additionalProperties": false + }, + "FxInputRef": { + "id": "FxInputRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "fxInput" + }, + "fxInputPath": { + "$ref": "#/RefPath" + } + }, + "required": [ + "realm", + "fxInputPath" + ], + "additionalProperties": false + }, + "MatteRef": { + "id": "MatteRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "matte" + }, + "mattePath": { + "$ref": "#/RefPath" + } + }, + "required": [ + "realm", + "mattePath" + ], + "additionalProperties": false + }, + "MediaClipRef": { + "id": "MediaClipRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "media-clip" + }, + "clipPath": { + "$ref": "#/RefPath" + } + }, + "required": [ + "realm", + "clipPath" + ], + "additionalProperties": false + }, + "MediaStillRef": { + "id": "MediaStillRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "media-still" + }, + "clipPath": { + "$ref": "#/RefPath" + } + }, + "required": [ + "realm", + "clipPath" + ], + "additionalProperties": false + }, + "MediaRamRecRef": { + "id": "MediaRamRecRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "media-ramrec" + }, + "clipPath": { + "$ref": "#/RefPath" + } + }, + "required": [ + "realm", + "clipPath" + ], + "additionalProperties": false + }, + "MediaImageRef": { + "id": "MediaImageRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "media-image" + }, + "clipPath": { + "$ref": "#/RefPath" + } + }, + "required": [ + "realm", + "clipPath" + ], + "additionalProperties": false + }, + "MediaSoundRef": { + "id": "MediaSoundRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "media-sound" + }, + "clipPath": { + "$ref": "#/RefPath" + } + }, + "required": [ + "realm", + "clipPath" + ], + "additionalProperties": false + }, + "MacroRef": { + "id": "MacroRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "macro" + }, + "macroPath": { + "$ref": "#/RefPath" + } + }, + "required": [ + "realm", + "macroPath" + ], + "additionalProperties": false + }, + "RamRecorderRef": { + "id": "RamRecorderRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "ramRecorder" + }, + "path": { + "type": "array", + "prefixItems": [ + { + "type": "string", + "enum": [ + "RR1", + "RR2", + "RR3", + "RR4", + "RR5", + "RR6", + "RR7", + "RR8" + ] + } + ] + } + }, + "required": [ + "realm", + "path" + ], + "additionalProperties": false + }, + "ClipPlayerRef": { + "id": "ClipPlayerRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "clipPlayer" + }, + "path": { + "type": "array", + "prefixItems": [ + { + "type": "string", + "enum": [ + "CP1", + "CP2" + ] + } + ] + } + }, + "required": [ + "realm", + "path" + ], + "additionalProperties": false + }, + "ImageStoreRef": { + "id": "ImageStoreRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "imageStore" + }, + "path": { + "type": "array", + "prefixItems": [ + { + "type": "string", + "enum": [ + "IS1", + "IS2", + "IS3", + "IS4", + "IS5", + "IS6", + "IS7", + "IS8" + ] + } + ] + } + }, + "required": [ + "realm", + "path" + ], + "additionalProperties": false + }, + "GfxSceneRef": { + "id": "GfxSceneRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "gfxScene" + }, + "scenePath": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "realm", + "scenePath" + ], + "additionalProperties": false + }, + "GfxSceneItemRef": { + "id": "GfxSceneItemRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "gfxScene-item" + }, + "scenePath": { + "type": "array", + "items": { + "type": "string" + } + }, + "sceneItemPath": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "realm", + "scenePath", + "sceneItemPath" + ], + "additionalProperties": false + }, + "SourceBaseRef": { + "id": "SourceBaseRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "source-base" + }, + "path": { + "type": "array", + "prefixItems": [ + { + "type": "string", + "enum": [ + "BLACK", + "WHITE" + ] + } + ] + } + }, + "required": [ + "realm", + "path" + ], + "additionalProperties": false + }, + "SourceIntRef": { + "id": "SourceIntRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "source-int" + }, + "path": { + "type": "array", + "prefixItems": [ + { + "type": "string", + "enum": [ + "ColorBar", + "ColorCircle", + "MV1", + "MV2", + "MV3", + "MV4" + ] + } + ] + } + }, + "required": [ + "realm", + "path" + ], + "additionalProperties": false + }, + "AudioMixerChannelRef": { + "id": "AudioMixerChannelRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "audioMixer-channel" + }, + "channelPath": { + "$ref": "#/RefPath" + } + }, + "required": [ + "realm", + "channelPath" + ], + "additionalProperties": false + }, + "MattesRef": { + "id": "MattesRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "mattes" + }, + "path": { + "$ref": "#/RefPath" + } + }, + "required": [ + "realm", + "path" + ], + "additionalProperties": false + }, + "AuxRef": { + "id": "AuxRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "aux" + }, + "path": { + "type": "string" + }, + "pathIsName": { + "type": "boolean", + "description": "true if the path is a name, false if it is an id" + } + }, + "required": [ + "realm", + "path", + "pathIsName" + ], + "additionalProperties": false + }, + "InputRef": { + "id": "InputRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "input" + }, + "path": { + "type": "string" + } + }, + "required": [ + "realm", + "path" + ], + "additionalProperties": false + }, + "AuxEffectRef": { + "id": "AuxEffectRef", + "type": "object", + "properties": { + "realm": { + "type": "string", + "const": "aux-effect" + }, + "auxPath": { + "type": "string" + }, + "auxPathIsName": { + "type": "boolean", + "description": "true if the path is a name, false if it is an id" + }, + "effectPath": { + "$ref": "#/RefPath" + } + }, + "required": [ + "realm", + "auxPath", + "auxPathIsName", + "effectPath" + ], + "additionalProperties": false + } +} diff --git a/packages/timeline-state-resolver/src/integrations/kairos/$schemas/mappings.json b/packages/timeline-state-resolver/src/integrations/kairos/$schemas/mappings.json new file mode 100644 index 0000000000..28c667efd9 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/$schemas/mappings.json @@ -0,0 +1,218 @@ +{ + "$schema": "../../../../../timeline-state-resolver-api/$schemas/mapping-schema.json", + "default": "scene-layer", + "mappings": { + "scene": { + "type": "object", + "properties": { + "sceneName": { + "type": "array", + "items": { + "type": "string" + }, + "ui:title": "Scene", + "ui:description": "The Scene to use", + "ui:summaryTitle": "Scene", + "ui:displayType": "breadcrumbs", + "default": [ + "Main" + ] + } + }, + "required": [ + "sceneName" + ], + "additionalProperties": false + }, + "scene-layer": { + "type": "object", + "properties": { + "sceneName": { + "type": "array", + "items": { + "type": "string" + }, + "ui:title": "Scene", + "ui:description": "The Scene to use", + "ui:summaryTitle": "Scene", + "ui:displayType": "breadcrumbs", + "default": [ + "Main" + ] + }, + "layerName": { + "type": "array", + "items": { + "type": "string" + }, + "ui:title": "Layer", + "ui:description": "The layer in a scene to use", + "ui:summaryTitle": "Layer", + "ui:displayType": "breadcrumbs", + "default": [ + "Background" + ] + } + }, + "required": [ + "sceneName", + "layerName" + ], + "additionalProperties": false + }, + "aux": { + "type": "object", + "properties": { + "auxName": { + "type": "string", + "ui:title": "AUX", + "ui:description": "The AUX name to use", + "ui:summaryTitle": "AUX", + "ui:displayType": "breadcrumbs", + "default": "IP-AUX1" + } + }, + "required": [ + "auxName" + ], + "additionalProperties": false + }, + "macro": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "clip-player": { + "type": "object", + "properties": { + "playerId": { + "type": "number", + "ui:title": "Player ID", + "ui:description": "The player", + "ui:summaryTitle": "ID", + "ui:displayType": "breadcrumbs", + "default": 1, + "min": 1 + }, + "framerate": { + "type": "number", + "ui:title": "Framerate", + "ui:description": "The framerate of the clip player (default: 25)", + "ui:summaryTitle": "Framerate", + "ui:displayType": "breadcrumbs", + "default": 25, + "min": 1 + }, + "clearPlayerOnStop": { + "type": "boolean", + "ui:title": "Clear Player on Stop", + "ui:description": "Whether the player should be cleared when a clip is stopped", + "ui:summaryTitle": "Clear Player on Stop", + "ui:displayType": "breadcrumbs", + "default": false + } + }, + "required": [ + "playerId" + ], + "additionalProperties": false + }, + "ram-rec-player": { + "type": "object", + "properties": { + "playerId": { + "type": "number", + "ui:title": "Player ID", + "ui:description": "The player", + "ui:summaryTitle": "ID", + "ui:displayType": "breadcrumbs", + "default": 1, + "min": 1 + }, + "framerate": { + "type": "number", + "ui:title": "Framerate", + "ui:description": "The framerate of the clip player (default: 25)", + "ui:summaryTitle": "Framerate", + "ui:displayType": "breadcrumbs", + "default": 25, + "min": 1 + }, + "clearPlayerOnStop": { + "type": "boolean", + "ui:title": "Clear Player on Stop", + "ui:description": "Whether the player should be cleared when a clip is stopped", + "ui:summaryTitle": "Clear Player on Stop", + "ui:displayType": "breadcrumbs", + "default": false + } + }, + "required": [ + "playerId" + ], + "additionalProperties": false + }, + "image-store": { + "type": "object", + "properties": { + "playerId": { + "type": "number", + "ui:title": "Player ID", + "ui:description": "The player", + "ui:summaryTitle": "ID", + "ui:displayType": "breadcrumbs", + "default": 1, + "min": 1 + }, + "clearPlayerOnStop": { + "type": "boolean", + "ui:title": "Clear Player on Stop", + "ui:description": "Whether the player should be cleared when a clip is stopped", + "ui:summaryTitle": "Clear Player on Stop", + "ui:displayType": "breadcrumbs", + "default": false + } + }, + "required": [ + "playerId" + ], + "additionalProperties": false + }, + "sound-player": { + "type": "object", + "properties": { + "playerId": { + "type": "number", + "ui:title": "Player ID", + "ui:description": "The player", + "ui:summaryTitle": "ID", + "ui:displayType": "breadcrumbs", + "default": 1, + "min": 1 + }, + "framerate": { + "type": "number", + "ui:title": "Framerate", + "ui:description": "The framerate of the clip player (default: 25)", + "ui:summaryTitle": "Framerate", + "ui:displayType": "breadcrumbs", + "default": 25, + "min": 1 + }, + "clearPlayerOnStop": { + "type": "boolean", + "ui:title": "Clear Player on Stop", + "ui:description": "Whether the player should be cleared when a clip is stopped", + "ui:summaryTitle": "Clear Player on Stop", + "ui:displayType": "breadcrumbs", + "default": false + } + }, + "required": [ + "playerId" + ], + "additionalProperties": false + } + } +} diff --git a/packages/timeline-state-resolver/src/integrations/kairos/$schemas/options.json b/packages/timeline-state-resolver/src/integrations/kairos/$schemas/options.json new file mode 100644 index 0000000000..46414f1168 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/$schemas/options.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "KAIROS Options", + "type": "object", + "properties": { + "host": { + "type": "string", + "description": "Host of KAIROS server", + "ui:title": "Host", + "default": "" + }, + "port": { + "type": "integer", + "description": "Port of KAIROS server", + "ui:title": "Port", + "default": 3005 + } + }, + "required": ["host"], + "additionalProperties": false +} diff --git a/packages/timeline-state-resolver/src/integrations/kairos/__tests__/diffState.spec.ts b/packages/timeline-state-resolver/src/integrations/kairos/__tests__/diffState.spec.ts new file mode 100644 index 0000000000..6b9cce06b1 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/__tests__/diffState.spec.ts @@ -0,0 +1,520 @@ +import { + DeviceType, + MappingKairosType, + Mappings, + SomeMappingKairos, + TimelineContentTypeKairos, +} from 'timeline-state-resolver-types' +import { KairosCommandWithContext } from '..' +import { diffKairosStates } from '../diffState' +import { KairosDeviceState, KairosStateBuilder } from '../stateBuilder' +import { makeDeviceTimelineStateObject } from '../../../__mocks__/objects' +import { refIpInput } from 'kairos-connection' + +describe('diffState', () => { + const DEFAULT_MAPPINGS: Mappings = { + mainScene: { + device: DeviceType.KAIROS, + deviceId: 'kairos0', + options: { + mappingType: MappingKairosType.Scene, + sceneName: ['Main'], + }, + }, + mainSceneBackgroundLayer: { + device: DeviceType.KAIROS, + deviceId: 'kairos0', + options: { + mappingType: MappingKairosType.SceneLayer, + sceneName: ['Main'], + layerName: ['Background'], + }, + }, + clipPlayer1: { + device: DeviceType.KAIROS, + deviceId: 'kairos0', + options: { + mappingType: MappingKairosType.ClipPlayer, + playerId: 1, + }, + }, + } + let now = 10000 + beforeEach(() => { + now = 10000 + }) + test('empty state to empty state', () => { + compareStates(DEFAULT_MAPPINGS, undefined, { ...EMPTY_STATE, stateTime: 0 }, []) + }) + test('Assign Camera1 to SceneLayer', () => { + compareStates( + DEFAULT_MAPPINGS, + { ...EMPTY_STATE, stateTime: now }, + KairosStateBuilder.fromTimeline( + { + objects: [ + makeDeviceTimelineStateObject({ + enable: { start: now }, + id: 'obj0', + layer: 'mainSceneBackgroundLayer', + content: { + deviceType: DeviceType.KAIROS, + type: TimelineContentTypeKairos.SCENE_LAYER, + sceneLayer: { + sourcePgm: refIpInput(1), + }, + }, + }), + ], + time: now, + }, + DEFAULT_MAPPINGS + ), + [ + { + context: expect.any(String), + timelineObjId: expect.any(String), + command: { + type: 'scene-layer', + sceneLayerId: 'SCENES.Main.Layers.Background', + ref: { + realm: 'scene-layer', + scenePath: ['Main'], + layerPath: ['Background'], + }, + values: { + sourcePgm: { + realm: 'ip-input', + ipInput: 1, + }, + }, + }, + }, + ] + ) + }) + describe('Clip Players', () => { + test('Play a clip, then pause it, then resume, then clear', () => { + let oldState = { ...EMPTY_STATE, stateTime: now } + + // Play a clip: ----------------------------------------------------------------------- + let newState = KairosStateBuilder.fromTimeline( + { + objects: [ + makeDeviceTimelineStateObject({ + enable: { start: now }, + id: 'obj0', + layer: 'clipPlayer1', + content: { + deviceType: DeviceType.KAIROS, + type: TimelineContentTypeKairos.CLIP_PLAYER, + clipPlayer: { + clip: { realm: 'media-clip', clipPath: ['amb.mp4'] }, + playing: true, + }, + }, + }), + ], + time: now, + }, + DEFAULT_MAPPINGS + ) + compareStates(DEFAULT_MAPPINGS, oldState, newState, [ + // Preload command: + { + context: expect.any(String), + timelineObjId: expect.any(String), + command: { + type: 'clip-player', + playerId: 1, + values: { + clip: { + realm: 'media-clip', + clipPath: ['amb.mp4'], + }, + repeat: false, + }, + }, + preliminary: 10, + }, + // Play command: + { + context: expect.any(String), + timelineObjId: expect.any(String), + command: { + type: 'media-player:do', + playerType: 'clip-player', + playerId: 1, + command: 'play', + }, + }, + ]) + + // check again, when nothing has changed: + now += 1000 + oldState = newState + compareStates(DEFAULT_MAPPINGS, oldState, newState, []) + + // Pause the clip --------------------------------------------------------------------- + now += 1000 + newState = KairosStateBuilder.fromTimeline( + { + objects: [ + makeDeviceTimelineStateObject({ + enable: { start: now }, + id: 'obj0', + layer: 'clipPlayer1', + content: { + deviceType: DeviceType.KAIROS, + type: TimelineContentTypeKairos.CLIP_PLAYER, + clipPlayer: { + clip: { realm: 'media-clip', clipPath: ['amb.mp4'] }, + playing: false, // Is paused + }, + }, + }), + ], + time: now, + }, + DEFAULT_MAPPINGS + ) + compareStates(DEFAULT_MAPPINGS, oldState, newState, [ + { + context: expect.any(String), + timelineObjId: expect.any(String), + command: { + type: 'clip-player', + playerId: 1, + + values: { + clip: { + realm: 'media-clip', + clipPath: ['amb.mp4'], + }, + }, + }, + preliminary: 0, + }, + { + context: expect.any(String), + timelineObjId: expect.any(String), + command: { + type: 'media-player:do', + playerType: 'clip-player', + playerId: 1, + command: 'pause', + }, + }, + ]) + // check again, when nothing has changed: + now += 1000 + oldState = newState + compareStates(DEFAULT_MAPPINGS, oldState, newState, []) + + // Resume playing --------------------------------------------------------------------- + now += 1000 + oldState = newState + newState = KairosStateBuilder.fromTimeline( + { + objects: [ + makeDeviceTimelineStateObject({ + enable: { start: now }, + id: 'obj0', + layer: 'clipPlayer1', + content: { + deviceType: DeviceType.KAIROS, + type: TimelineContentTypeKairos.CLIP_PLAYER, + clipPlayer: { + clip: { realm: 'media-clip', clipPath: ['amb.mp4'] }, + playing: true, // Is playing + }, + }, + }), + ], + time: now, + }, + DEFAULT_MAPPINGS + ) + compareStates(DEFAULT_MAPPINGS, oldState, newState, [ + { + context: expect.any(String), + timelineObjId: expect.any(String), + command: { + type: 'clip-player', + playerId: 1, + values: { + clip: { + realm: 'media-clip', + clipPath: ['amb.mp4'], + }, + repeat: false, + }, + }, + preliminary: 10, + }, + { + context: expect.any(String), + timelineObjId: expect.any(String), + command: { + type: 'media-player:do', + playerType: 'clip-player', + playerId: 1, + command: 'play', + }, + }, + ]) + // check again, when nothing has changed: + now += 1000 + oldState = newState + compareStates(DEFAULT_MAPPINGS, oldState, newState, []) + + // Clear the clip --------------------------------------------------------------------- + now += 1000 + oldState = newState + newState = KairosStateBuilder.fromTimeline( + { + objects: [], + time: now, + }, + DEFAULT_MAPPINGS + ) // Empty state + compareStates(DEFAULT_MAPPINGS, oldState, newState, [ + { + context: expect.any(String), + timelineObjId: expect.any(String), + command: { + type: 'media-player:do', + playerType: 'clip-player', + playerId: 1, + command: 'stop', + }, + }, + ]) + // check again, when nothing has changed: + now += 1000 + oldState = newState + compareStates(DEFAULT_MAPPINGS, oldState, newState, []) + }) + test('Late start playing a Clip with seek', () => { + compareStates( + DEFAULT_MAPPINGS, + { ...EMPTY_STATE, stateTime: now }, + KairosStateBuilder.fromTimeline( + { + objects: [ + makeDeviceTimelineStateObject({ + enable: { + // The clip was supposed to start 0.5s ago: + start: now - 500, + }, + id: 'obj0', + layer: 'clipPlayer1', + content: { + deviceType: DeviceType.KAIROS, + type: TimelineContentTypeKairos.CLIP_PLAYER, + clipPlayer: { + clip: { + realm: 'media-clip', + clipPath: ['amb.mp4'], + }, + playing: true, + seek: 100, // should start 100ms into the clip (at the point of intended start) + }, + }, + }), + ], + time: now, + }, + DEFAULT_MAPPINGS + ), + [ + // Preload command: + { + context: expect.any(String), + timelineObjId: expect.any(String), + command: { + type: 'clip-player', + playerId: 1, + values: { + clip: { + realm: 'media-clip', + clipPath: ['amb.mp4'], + }, + repeat: false, + position: (600 / 1000) * 25, // The result should be to seek 600ms into the clip (now - (500 + 100)) + }, + }, + preliminary: 10, + }, + // Play command: + { + context: expect.any(String), + timelineObjId: expect.any(String), + command: { + type: 'media-player:do', + playerType: 'clip-player', + playerId: 1, + command: 'play', + }, + }, + ] + ) + }) + test('Load a clip, then start playing it (with late seek)', () => { + let oldState = { ...EMPTY_STATE, stateTime: now } + + // Load a clip: ----------------------------------------------------------------------- + let newState = KairosStateBuilder.fromTimeline( + { + objects: [ + makeDeviceTimelineStateObject({ + enable: { + // We're a bit late, should not affect the outcome though: + start: now - 500, + }, + id: 'obj0', + layer: 'clipPlayer1', + content: { + deviceType: DeviceType.KAIROS, + type: TimelineContentTypeKairos.CLIP_PLAYER, + clipPlayer: { + clip: { + realm: 'media-clip', + clipPath: ['amb.mp4'], + }, + playing: false, + seek: 100, // load it 100ms into the clip + }, + }, + }), + ], + time: now, + }, + DEFAULT_MAPPINGS + ) + compareStates(DEFAULT_MAPPINGS, oldState, newState, [ + // Preload command: + { + context: expect.any(String), + timelineObjId: expect.any(String), + command: { + type: 'clip-player', + playerId: 1, + values: { + clip: { + realm: 'media-clip', + clipPath: ['amb.mp4'], + }, + position: Math.floor((100 / 1000) * 25), + }, + }, + preliminary: 0, + }, + { + context: expect.any(String), + timelineObjId: expect.any(String), + command: { + type: 'media-player:do', + playerType: 'clip-player', + playerId: 1, + command: 'pause', + }, + }, + ]) + // check again, when nothing has changed: + now += 1000 + oldState = newState + compareStates(DEFAULT_MAPPINGS, oldState, newState, []) + + // Resume playing --------------------------------------------------------------------- + now += 1000 + oldState = newState + newState = KairosStateBuilder.fromTimeline( + { + objects: [ + makeDeviceTimelineStateObject({ + enable: { + start: now, + }, + id: 'obj0', + layer: 'clipPlayer1', + content: { + deviceType: DeviceType.KAIROS, + type: TimelineContentTypeKairos.CLIP_PLAYER, + clipPlayer: { + clip: { + realm: 'media-clip', + clipPath: ['amb.mp4'], + }, + playing: true, + seek: 100, + }, + }, + }), + ], + time: now, + }, + DEFAULT_MAPPINGS + ) + compareStates(DEFAULT_MAPPINGS, oldState, newState, [ + { + context: expect.any(String), + timelineObjId: expect.any(String), + command: { + type: 'clip-player', + playerId: 1, + values: { + clip: { + realm: 'media-clip', + clipPath: ['amb.mp4'], + }, + repeat: false, + position: Math.floor((100 / 1000) * 25), + }, + }, + preliminary: 10, + }, + { + context: expect.any(String), + timelineObjId: expect.any(String), + command: { + type: 'media-player:do', + playerType: 'clip-player', + playerId: 1, + command: 'play', + }, + }, + ]) + // check again, when nothing has changed: + now += 1000 + oldState = newState + compareStates(DEFAULT_MAPPINGS, oldState, newState, []) + }) + }) + // test('temporal order when cutting to/from a clip player before it has started/stopped playing', () => { + + // }) +}) + +function compareStates( + mappings: Mappings, + oldKairosState: KairosDeviceState | undefined, + newKairosState: KairosDeviceState, + expectedCommands: KairosCommandWithContext[] +) { + const commands = diffKairosStates(oldKairosState, newKairosState, mappings) + + expect(commands).toStrictEqual(expectedCommands) +} + +const EMPTY_STATE: Omit = { + aux: {}, + clipPlayers: {}, + macros: {}, + ramRecPlayers: {}, + sceneLayers: {}, + sceneSnapshots: {}, + scenes: {}, + soundPlayers: {}, + imageStores: {}, +} diff --git a/packages/timeline-state-resolver/src/integrations/kairos/__tests__/lib.ts b/packages/timeline-state-resolver/src/integrations/kairos/__tests__/lib.ts new file mode 100644 index 0000000000..e5545905fd --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/__tests__/lib.ts @@ -0,0 +1,13 @@ +import { KairosDeviceState } from '../stateBuilder' + +export const EMPTY_STATE: Omit = { + aux: {}, + clipPlayers: {}, + macros: {}, + ramRecPlayers: {}, + sceneLayers: {}, + sceneSnapshots: {}, + scenes: {}, + soundPlayers: {}, + imageStores: {}, +} diff --git a/packages/timeline-state-resolver/src/integrations/kairos/__tests__/stateBuilder.spec.ts b/packages/timeline-state-resolver/src/integrations/kairos/__tests__/stateBuilder.spec.ts new file mode 100644 index 0000000000..1dcb23440d --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/__tests__/stateBuilder.spec.ts @@ -0,0 +1,87 @@ +import { + DeviceType, + MappingKairosType, + Mappings, + SomeMappingKairos, + TimelineContentTypeKairos, +} from 'timeline-state-resolver-types' +import { KairosStateBuilder } from '../stateBuilder' +import { EMPTY_STATE } from './lib' +import { makeDeviceTimelineStateObject } from '../../../__mocks__/objects' +import { refIpInput } from 'kairos-connection' + +describe('stateBuilder', () => { + const DEFAULT_MAPPINGS: Mappings = { + mainScene: { + device: DeviceType.KAIROS, + deviceId: 'kairos0', + options: { + mappingType: MappingKairosType.Scene, + sceneName: ['Main'], + }, + }, + mainSceneBackgroundLayer: { + device: DeviceType.KAIROS, + deviceId: 'kairos0', + options: { + mappingType: MappingKairosType.SceneLayer, + sceneName: ['Main'], + layerName: ['Background'], + }, + }, + } + test('empty state', () => { + expect( + KairosStateBuilder.fromTimeline( + { + objects: [], + time: 123, + }, + DEFAULT_MAPPINGS + ) + ).toStrictEqual({ ...EMPTY_STATE, stateTime: 123 }) + }) + test('Set', () => { + expect( + KairosStateBuilder.fromTimeline( + { + objects: [ + makeDeviceTimelineStateObject({ + enable: { start: 10000 }, + id: 'obj0', + layer: 'mainSceneBackgroundLayer', + content: { + deviceType: DeviceType.KAIROS, + type: TimelineContentTypeKairos.SCENE_LAYER, + sceneLayer: { + sourcePgm: refIpInput(1), + }, + }, + }), + ], + time: 0, + }, + DEFAULT_MAPPINGS + ) + ).toStrictEqual({ + ...EMPTY_STATE, + stateTime: 0, + sceneLayers: { + 'SCENES.Main.Layers.Background': { + ref: { + layerPath: ['Background'], + realm: 'scene-layer', + scenePath: ['Main'], + }, + state: { + sourcePgm: { + ipInput: 1, + realm: 'ip-input', + }, + }, + timelineObjIds: ['obj0'], + }, + }, + }) + }) +}) diff --git a/packages/timeline-state-resolver/src/integrations/kairos/actions.ts b/packages/timeline-state-resolver/src/integrations/kairos/actions.ts new file mode 100644 index 0000000000..a544541dd5 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/actions.ts @@ -0,0 +1,182 @@ +import { + KairosConnection, + refScene, + refSceneLayer, + refMacro, + refAuxName, + refAuxId, + refGfxScene, + refSceneSnapshot, + // eslint-disable-next-line node/no-missing-import +} from 'kairos-connection' +import { ActionExecutionResultCode, KairosActionMethods, KairosActions } from 'timeline-state-resolver-types' + +export function getActions(kairos: KairosConnection): KairosActionMethods { + return { + [KairosActions.ListScenes]: async (payload) => { + return { + result: ActionExecutionResultCode.Ok, + resultData: await kairos.listScenes(refScene(payload.scenePath), payload.deep), + resultCode: 0, + } + }, + [KairosActions.ListSceneLayers]: async (payload) => { + return { + result: ActionExecutionResultCode.Ok, + resultData: await kairos.listSceneLayers( + refSceneLayer(refScene(payload.scenePath), payload.layerPath), + payload.deep + ), + resultCode: 0, + } + }, + [KairosActions.ListSceneLayerEffects]: async (payload) => { + return { + result: ActionExecutionResultCode.Ok, + resultData: await kairos.listSceneLayerEffects( + refSceneLayer(refScene(payload.scenePath), payload.layerPath), + payload.deep + ), + resultCode: 0, + } + }, + [KairosActions.ListSceneTransitions]: async (payload) => { + return { + result: ActionExecutionResultCode.Ok, + resultData: await kairos.listSceneTransitions(refScene(payload.scenePath)), + resultCode: 0, + } + }, + [KairosActions.ListSceneSnapshots]: async (payload) => { + return { + result: ActionExecutionResultCode.Ok, + resultData: await kairos.listSceneSnapshots(refScene(payload.scenePath)), + resultCode: 0, + } + }, + [KairosActions.ListFxInputs]: async (_payload) => { + return { + result: ActionExecutionResultCode.Ok, + resultData: await kairos.listFxInputs(), + resultCode: 0, + } + }, + [KairosActions.ListMattes]: async (_payload) => { + return { + result: ActionExecutionResultCode.Ok, + resultData: await kairos.listMattes(), + resultCode: 0, + } + }, + [KairosActions.ListMediaClips]: async (_payload) => { + return { + result: ActionExecutionResultCode.Ok, + resultData: await kairos.listMediaClips(), + resultCode: 0, + } + }, + [KairosActions.ListMediaStills]: async (_payload) => { + return { + result: ActionExecutionResultCode.Ok, + resultData: await kairos.listMediaStills(), + resultCode: 0, + } + }, + [KairosActions.ListMediaRamRec]: async (_payload) => { + return { + result: ActionExecutionResultCode.Ok, + resultData: await kairos.listMediaRamRecs(), + resultCode: 0, + } + }, + [KairosActions.ListMediaImage]: async (_payload) => { + return { + result: ActionExecutionResultCode.Ok, + resultData: await kairos.listMediaImages(), + resultCode: 0, + } + }, + [KairosActions.ListMediaSounds]: async (_payload) => { + return { + result: ActionExecutionResultCode.Ok, + resultData: await kairos.listMediaSounds(), + resultCode: 0, + } + }, + [KairosActions.ListMacros]: async (payload) => { + return { + result: ActionExecutionResultCode.Ok, + resultData: await kairos.listMacros(payload.macroPath && refMacro(payload.macroPath), payload.deep), + resultCode: 0, + } + }, + [KairosActions.ListAuxes]: async (_payload) => { + return { + result: ActionExecutionResultCode.Ok, + resultData: await kairos.listAuxes(), + resultCode: 0, + } + }, + [KairosActions.ListAuxEffects]: async (payload) => { + return { + result: ActionExecutionResultCode.Ok, + resultData: await kairos.listAuxEffects( + payload.pathIsName ? refAuxName(payload.path) : refAuxId(payload.path), + payload.deep + ), + resultCode: 0, + } + }, + [KairosActions.ListGfxScenes]: async (payload) => { + return { + result: ActionExecutionResultCode.Ok, + resultData: await kairos.listGfxScenes( + payload.scenePath ? refGfxScene(payload.scenePath) : refGfxScene([]), + payload.deep + ), + resultCode: 0, + } + }, + [KairosActions.ListGfxSceneItems]: async (payload) => { + return { + result: ActionExecutionResultCode.Ok, + resultData: await kairos.listGfxSceneItems(refGfxScene(payload.scenePath)), + resultCode: 0, + } + }, + [KairosActions.ListAudioMixerChannels]: async (_payload) => { + return { + result: ActionExecutionResultCode.Ok, + resultData: await kairos.listAudioMixerChannels(), + resultCode: 0, + } + }, + [KairosActions.MacroPlay]: async (payload) => { + if (!payload.macroPath || !Array.isArray(payload.macroPath)) { + return { result: ActionExecutionResultCode.Error, message: 'Invalid payload: macroPath is not an Array' } + } + + return { + result: ActionExecutionResultCode.Ok, + resultData: await kairos.macroPlay(refMacro(payload.macroPath)), + resultCode: 0, + } + }, + [KairosActions.MacroStop]: async (payload) => { + return { + result: ActionExecutionResultCode.Ok, + resultData: await kairos.macroStop(refMacro(payload.macroPath)), + resultCode: 0, + } + }, + [KairosActions.SceneSnapshotRecall]: async (payload) => { + return { + result: ActionExecutionResultCode.Ok, + resultData: await kairos.sceneSnapshotRecall( + refSceneSnapshot(refScene(payload.scenePath), payload.snapshotPath) + ), + resultCode: 0, + } + }, + } satisfies KairosActionMethods +} diff --git a/packages/timeline-state-resolver/src/integrations/kairos/commands.ts b/packages/timeline-state-resolver/src/integrations/kairos/commands.ts new file mode 100644 index 0000000000..f30dd851f3 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/commands.ts @@ -0,0 +1,400 @@ +import { + type UpdateSceneObject, + type UpdateSceneLayerObject, + type UpdateAuxObject, + type UpdateClipPlayerObject, + type SceneRef, + type SceneLayerRef, + type AuxRef, + type MacroRef, + type SceneSnapshotRef, + type KairosConnection, + type UpdateRamRecPlayerObject, + type UpdateAudioPlayerObject, + type UpdateImageStoreObject, + isRef, + // eslint-disable-next-line node/no-missing-import +} from 'kairos-connection' +import { assertNever } from '../../lib' +import { isEqual } from 'underscore' +import type { KairosRamLoader } from './lib/kairosRamLoader' + +export type KairosCommandAny = + | KairosSceneCommand + | KairosSceneRecallSnapshotCommand + | KairosSceneLayerCommand + | KairosAuxCommand + | KairosMacroCommand + | KairosClipPlayerCommand + | KairosRamRecPlayerCommand + | KairosImageStoreCommand + | KairosSoundPlayerCommand + | KairosPlayerCommandMethod + +export interface KairosSceneCommand { + type: 'scene' + + ref: SceneRef + + values: Partial +} + +export interface KairosSceneRecallSnapshotCommand { + type: 'scene-recall-snapshot' + + ref: SceneSnapshotRef + + snapshotName: string + active: boolean | undefined +} + +export interface KairosSceneLayerCommand { + type: 'scene-layer' + + ref: SceneLayerRef + sceneLayerId: string + + values: Partial +} + +export interface KairosAuxCommand { + type: 'aux' + + ref: AuxRef + + values: Partial +} + +export interface KairosMacroCommand { + type: 'macro' + + macroName: string + macroRef: MacroRef + active: boolean +} + +export interface KairosClipPlayerCommand { + type: 'clip-player' + + playerId: number + + values: Partial +} +export interface KairosPlayerCommandMethod { + type: 'media-player:do' + playerId: number + playerType: 'clip-player' | 'ram-rec-player' | 'sound-player' + command: + | 'begin' + | 'rewind' + | 'stepBack' + | 'reverse' + | 'play' + | 'pause' + | 'stop' + | 'stepForward' + | 'fastForward' + | 'end' + | 'playlistBegin' + | 'playlistBack' + | 'playlistNext' + | 'playlistEnd' +} + +export interface KairosRamRecPlayerCommand { + type: 'ram-rec-player' + + playerId: number + + values: Partial +} + +export interface KairosImageStoreCommand { + type: 'image-store' + + playerId: number + + values: Partial +} + +export interface KairosSoundPlayerCommand { + type: 'sound-player' + + playerId: number + + values: Partial +} + +export async function sendCommand( + kairos: KairosConnection, + kairosRamLoader: KairosRamLoader, + command: KairosCommandAny +): Promise { + const commandType = command.type + switch (command.type) { + case 'scene': + await kairos.updateScene(command.ref, command.values) + break + case 'scene-recall-snapshot': + if (command.active === true) { + await kairos.sceneSnapshotRecall(command.ref) + } else if (command.active === false) { + await kairos.sceneSnapshotAbort(command.ref) + } else if (command.active === undefined) { + // Do nothing + } else { + assertNever(command.active) + } + break + case 'scene-layer': { + const values = { ...command.values } + if (values.sourceA) { + // Handle loading ramrec/still into RAM if needed + const source = values.sourceA + delete values.sourceA + + await kairos.updateSceneLayer(command.ref, { sourceA: source }) + + await kairosRamLoader.ensureRAMLoaded(`sceneLayer_${command.sceneLayerId}`, source, (currentState) => { + // This is called after the RAM load is done + const sceneLayer = currentState.sceneLayers[command.sceneLayerId] + if (sceneLayer && isEqual(sceneLayer.state.sourceA, source)) { + // Only modify if it is the same source: + delete sceneLayer.state.sourceA + return currentState + } else return false + }) + } + if (values.sourcePgm) { + // Handle loading ramrec/still into RAM if needed + const source = values.sourcePgm + delete values.sourcePgm + + await kairos.updateSceneLayer(command.ref, { sourcePgm: source }) + + await kairosRamLoader.ensureRAMLoaded(`sceneLayer_${command.sceneLayerId}`, source, (currentState) => { + // This is called after the RAM load is done + const sceneLayer = currentState.sceneLayers[command.sceneLayerId] + if (sceneLayer && isEqual(sceneLayer.state.sourcePgm, source)) { + // Only modify if it is the same still: + delete sceneLayer.state.sourcePgm + return currentState + } else return false + }) + } + if (values.sourcePst) { + // Handle loading ramrec/still into RAM if needed + const source = values.sourcePst + delete values.sourcePst + + await kairos.updateSceneLayer(command.ref, { sourcePst: source }) + + await kairosRamLoader.ensureRAMLoaded(`sceneLayer_${command.sceneLayerId}`, source, (currentState) => { + // This is called after the RAM load is done + const sceneLayer = currentState.sceneLayers[command.sceneLayerId] + if (sceneLayer && isEqual(sceneLayer.state.sourcePst, source)) { + // Only modify if it is the same still: + delete sceneLayer.state.sourcePst + return currentState + } else return false + }) + } + + await kairos.updateSceneLayer(command.ref, values) + break + } + case 'aux': + await kairos.updateAux(command.ref, command.values) + break + case 'macro': + if (command.active) { + await kairos.macroRecall(command.macroRef) + } else { + await kairos.macroStop(command.macroRef) + } + break + case 'clip-player': { + const values = { ...command.values } + if (values.clip) { + await kairos.loadClipPlayerClip(command.playerId, values.clip, values.position) + delete values.clip + delete values.position + } + await kairos.updateClipPlayer(command.playerId, values) + break + } + case 'media-player:do': { + switch (command.command) { + case 'begin': { + if (command.playerType === 'clip-player') await kairos.clipPlayerBegin(command.playerId) + else if (command.playerType === 'ram-rec-player') await kairos.ramRecorderBegin(command.playerId) + else if (command.playerType === 'sound-player') await kairos.audioPlayerBegin(command.playerId) + else assertNever(command.playerType) + break + } + case 'rewind': { + if (command.playerType === 'clip-player') await kairos.clipPlayerRewind(command.playerId) + else if (command.playerType === 'ram-rec-player') await kairos.ramRecorderRewind(command.playerId) + else if (command.playerType === 'sound-player') await kairos.audioPlayerRewind(command.playerId) + else assertNever(command.playerType) + break + } + case 'stepBack': { + if (command.playerType === 'clip-player') await kairos.clipPlayerStepBack(command.playerId) + else if (command.playerType === 'ram-rec-player') await kairos.ramRecorderStepBack(command.playerId) + else if (command.playerType === 'sound-player') await kairos.audioPlayerStepBack(command.playerId) + else assertNever(command.playerType) + break + } + case 'reverse': { + if (command.playerType === 'clip-player') await kairos.clipPlayerReverse(command.playerId) + else if (command.playerType === 'ram-rec-player') await kairos.ramRecorderReverse(command.playerId) + else if (command.playerType === 'sound-player') await kairos.audioPlayerReverse(command.playerId) + else assertNever(command.playerType) + break + } + case 'play': { + if (command.playerType === 'clip-player') await kairos.clipPlayerPlay(command.playerId) + else if (command.playerType === 'ram-rec-player') await kairos.ramRecorderPlay(command.playerId) + else if (command.playerType === 'sound-player') await kairos.audioPlayerPlay(command.playerId) + else assertNever(command.playerType) + break + } + case 'pause': { + if (command.playerType === 'clip-player') await kairos.clipPlayerPause(command.playerId) + else if (command.playerType === 'ram-rec-player') await kairos.ramRecorderPause(command.playerId) + else if (command.playerType === 'sound-player') await kairos.audioPlayerPause(command.playerId) + else assertNever(command.playerType) + break + } + case 'stop': { + if (command.playerType === 'clip-player') await kairos.clipPlayerStop(command.playerId) + else if (command.playerType === 'ram-rec-player') await kairos.ramRecorderStop(command.playerId) + else if (command.playerType === 'sound-player') await kairos.audioPlayerStop(command.playerId) + else assertNever(command.playerType) + break + } + case 'stepForward': { + if (command.playerType === 'clip-player') await kairos.clipPlayerStepForward(command.playerId) + else if (command.playerType === 'ram-rec-player') await kairos.ramRecorderStepForward(command.playerId) + else if (command.playerType === 'sound-player') await kairos.audioPlayerStepForward(command.playerId) + else assertNever(command.playerType) + break + } + case 'fastForward': { + if (command.playerType === 'clip-player') await kairos.clipPlayerFastForward(command.playerId) + else if (command.playerType === 'ram-rec-player') await kairos.ramRecorderFastForward(command.playerId) + else if (command.playerType === 'sound-player') await kairos.audioPlayerFastForward(command.playerId) + else assertNever(command.playerType) + break + } + case 'end': { + if (command.playerType === 'clip-player') await kairos.clipPlayerEnd(command.playerId) + else if (command.playerType === 'ram-rec-player') await kairos.ramRecorderEnd(command.playerId) + else if (command.playerType === 'sound-player') await kairos.audioPlayerEnd(command.playerId) + else assertNever(command.playerType) + break + } + case 'playlistBegin': { + if (command.playerType === 'clip-player') await kairos.clipPlayerPlaylistBegin(command.playerId) + else if (command.playerType === 'ram-rec-player') await kairos.ramRecorderPlaylistBegin(command.playerId) + else if (command.playerType === 'sound-player') await kairos.audioPlayerPlaylistBegin(command.playerId) + else assertNever(command.playerType) + break + } + case 'playlistBack': { + if (command.playerType === 'clip-player') await kairos.clipPlayerPlaylistBack(command.playerId) + else if (command.playerType === 'ram-rec-player') await kairos.ramRecorderPlaylistBack(command.playerId) + else if (command.playerType === 'sound-player') await kairos.audioPlayerPlaylistBack(command.playerId) + else assertNever(command.playerType) + break + } + case 'playlistNext': { + if (command.playerType === 'clip-player') await kairos.clipPlayerPlaylistNext(command.playerId) + else if (command.playerType === 'ram-rec-player') await kairos.ramRecorderPlaylistNext(command.playerId) + else if (command.playerType === 'sound-player') await kairos.audioPlayerPlaylistNext(command.playerId) + else assertNever(command.playerType) + break + } + case 'playlistEnd': { + if (command.playerType === 'clip-player') await kairos.clipPlayerPlaylistEnd(command.playerId) + else if (command.playerType === 'ram-rec-player') await kairos.ramRecorderPlaylistEnd(command.playerId) + else if (command.playerType === 'sound-player') await kairos.audioPlayerPlaylistEnd(command.playerId) + else assertNever(command.playerType) + break + } + default: + assertNever(command.command) + throw new Error(`Unknown Kairos command.command type: ${command.command}`) + } + break + } + case 'ram-rec-player': { + const values = { ...command.values } + if (values.clip) { + // Handle loading ramrec/still into RAM if needed + const clip = values.clip + delete values.clip + + await kairos.updateRamRecorder(command.playerId, { + clip: clip, + }) + + await kairosRamLoader.ensureRAMLoaded(command.playerId, clip, (currentState) => { + // This is called after the RAM load is done, + // + const player = currentState.ramRecPlayers[command.playerId] + if (player && isEqual(player.state.content.clip, clip)) { + // Only modify if it is the same clip: + // delete player.state.content.clip + delete currentState.ramRecPlayers[command.playerId] + return currentState + } + + return false + }) + } + + await kairos.updateRamRecorder(command.playerId, values) + break + } + case 'image-store': { + const values = { ...command.values } + if (values.clip) { + // Handle loading ramrec/still into RAM if needed + const clip = values.clip + delete values.clip + + await kairos.updateImageStore(command.playerId, { + clip: clip, + }) + + if (isRef(clip) && clip.realm === 'media-still') { + // type guard + + await kairosRamLoader.ensureRAMLoaded(command.playerId, clip, (currentState) => { + // This is called after the RAM load is done, + // + const player = currentState.imageStores[command.playerId] + if (player && isEqual(player.state.content.imageStore.clip, clip)) { + // Only modify if it is the same still + // player.state.content.imageStore.clip = null + delete currentState.imageStores[command.playerId] + return currentState + } + return false + }) + } + } + + await kairos.updateImageStore(command.playerId, values) + break + } + case 'sound-player': + await kairos.updateAudioPlayer(command.playerId, command.values) + break + default: + assertNever(command) + throw new Error(`Unknown Kairos command type: ${commandType}`) + } +} diff --git a/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts b/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts new file mode 100644 index 0000000000..de9283a8e9 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts @@ -0,0 +1,231 @@ +import type { SomeMappingKairos, Mappings } from 'timeline-state-resolver-types' +import { KairosMacroActiveState } from 'timeline-state-resolver-types' +import { KairosStateBuilder, type KairosDeviceState } from './stateBuilder' +import type { KairosCommandWithContext } from '.' +// eslint-disable-next-line node/no-missing-import +import { UpdateSceneLayerObject, UpdateSceneObject, UpdateAuxObject } from 'kairos-connection' + +import { diffMediaPlayers, diffMediaImageStore } from './diffState/media-players' +import { diffObject, getAllKeysString } from './diffState/lib' + +export function diffKairosStates( + oldKairosState: KairosDeviceState | undefined, + newKairosState: KairosDeviceState, + mappings: Mappings +): KairosCommandWithContext[] { + // Make sure there is something to diff against + oldKairosState = + oldKairosState ?? + KairosStateBuilder.fromTimeline( + { + time: 0, + objects: [], + }, + mappings + ) + + const commands: KairosCommandWithContext[] = [] + + // TODO - any concerns with temporal order (ie, cutting to/from a clip player before it has started/stopped playing?) + + commands.push(...diffSceneSnapshots(oldKairosState.sceneSnapshots, newKairosState.sceneSnapshots)) + commands.push(...diffScenes(oldKairosState.scenes, newKairosState.scenes)) + commands.push(...diffSceneLayers(oldKairosState.sceneLayers, newKairosState.sceneLayers)) + + commands.push(...diffAuxes(oldKairosState.aux, newKairosState.aux)) + + commands.push(...diffMacros(oldKairosState.macros, newKairosState.macros)) + + commands.push( + ...diffMediaPlayers(newKairosState.stateTime, 'clip-player', oldKairosState.clipPlayers, newKairosState.clipPlayers) + ) + commands.push( + ...diffMediaPlayers( + newKairosState.stateTime, + 'ram-rec-player', + oldKairosState.ramRecPlayers, + newKairosState.ramRecPlayers + ) + ) + commands.push( + ...diffMediaPlayers( + newKairosState.stateTime, + 'sound-player', + oldKairosState.soundPlayers, + newKairosState.soundPlayers + ) + ) + commands.push(...diffMediaImageStore(oldKairosState.imageStores, newKairosState.imageStores)) + + return commands +} + +// const SceneDefaults: UpdateSceneObject = { +// advancedResolutionControl: false, +// } + +function diffScenes( + oldScenes: KairosDeviceState['scenes'], + newScenes: KairosDeviceState['scenes'] +): KairosCommandWithContext[] { + const commands: KairosCommandWithContext[] = [] + + const sceneKeys = getAllKeysString(oldScenes, newScenes) + for (const sceneKey of sceneKeys) { + const newScene = newScenes[sceneKey] + const oldScene = oldScenes[sceneKey] + + const sceneRef = newScene?.ref || oldScene?.ref + if (!sceneRef) continue // No scene to diff + + // const oldSceneState: UpdateSceneObject = { ...SceneDefaults, ...oldScene?.state } + // const newSceneState: UpdateSceneObject = { ...SceneDefaults, ...newScene?.state } + + const diff = diffObject(oldScene?.state, newScene?.state) + if (diff) { + commands.push({ + timelineObjId: newScene?.timelineObjIds.join(' & ') ?? '', + context: `sceneKey=${sceneKey} newScene=${!!newScene} oldScene=${!!oldScene}`, + command: { + type: 'scene', + ref: sceneRef, + values: diff, + }, + }) + } + } + + return commands +} +function diffSceneLayers( + oldSceneLayers: KairosDeviceState['sceneLayers'], + newSceneLayers: KairosDeviceState['sceneLayers'] +): KairosCommandWithContext[] { + const commands: KairosCommandWithContext[] = [] + + const sceneLayerKeys = getAllKeysString(oldSceneLayers, newSceneLayers) + for (const sceneLayerKey of sceneLayerKeys) { + const newSceneLayer = newSceneLayers[sceneLayerKey] + const oldSceneLayer = oldSceneLayers[sceneLayerKey] + + const sceneLayerRef = newSceneLayer?.ref || oldSceneLayer?.ref + if (!sceneLayerRef) continue // No scene to diff + + // const oldSceneState: UpdateSceneObject = { ...SceneDefaults, ...oldScene?.state } + // const newSceneState: UpdateSceneObject = { ...SceneDefaults, ...newScene?.state } + + const diff = diffObject(oldSceneLayer?.state, newSceneLayer?.state) + if (diff) { + commands.push({ + timelineObjId: newSceneLayer?.timelineObjIds.join(' & ') ?? '', + context: `sceneLayerKey=${sceneLayerKey} newSceneLayer=${!!newSceneLayer} oldSceneLayer=${!!oldSceneLayer}`, + command: { + type: 'scene-layer', + ref: sceneLayerRef, + sceneLayerId: sceneLayerKey, + values: diff, + }, + }) + } + } + + return commands +} + +function diffSceneSnapshots( + oldSceneSnapshots: KairosDeviceState['sceneSnapshots'], + newSceneSnapshots: KairosDeviceState['sceneSnapshots'] +): KairosCommandWithContext[] { + const commands: KairosCommandWithContext[] = [] + + const sceneSnapshotKeys = getAllKeysString(oldSceneSnapshots, newSceneSnapshots) + for (const sceneSnapshotKey of sceneSnapshotKeys) { + const newSceneSnapshot = newSceneSnapshots[sceneSnapshotKey] + const oldSceneSnapshot = oldSceneSnapshots[sceneSnapshotKey] + + const sceneSnapshotRef = newSceneSnapshot?.ref || oldSceneSnapshot?.ref + if (!sceneSnapshotRef) continue // No scene snapshot to diff + + // Check if active state changed + const oldActive = oldSceneSnapshot?.state.active ?? undefined + const newActive = newSceneSnapshot?.state.active ?? undefined + + if (oldActive !== newActive) { + commands.push({ + timelineObjId: newSceneSnapshot?.timelineObjIds.join(' & ') ?? '', + context: `sceneSnapshotKey=${sceneSnapshotKey} newSceneSnapshot=${!!newSceneSnapshot} oldSceneSnapshot=${!!oldSceneSnapshot}`, + command: { + type: 'scene-recall-snapshot', + ref: sceneSnapshotRef, + snapshotName: sceneSnapshotKey, + active: newActive, + }, + }) + } + } + + return commands +} + +function diffAuxes(oldAuxes: KairosDeviceState['aux'], newAuxes: KairosDeviceState['aux']): KairosCommandWithContext[] { + const commands: KairosCommandWithContext[] = [] + + const auxKeys = getAllKeysString(oldAuxes, newAuxes) + for (const auxKey of auxKeys) { + const newAux = newAuxes[auxKey] + const oldAux = oldAuxes[auxKey] + + const auxRef = newAux?.ref || oldAux?.ref + if (!auxRef) continue // No aux to diff + + const diff = diffObject(oldAux?.state.aux, newAux?.state.aux) + if (diff) { + commands.push({ + timelineObjId: newAux?.timelineObjIds.join(' & ') ?? '', + context: `auxKey=${auxKey} newAux=${!!newAux} oldAux=${!!oldAux}`, + command: { + type: 'aux', + ref: auxRef, + values: diff, + }, + }) + } + } + + return commands +} + +function diffMacros( + oldMacros: KairosDeviceState['macros'], + newMacros: KairosDeviceState['macros'] +): KairosCommandWithContext[] { + const commands: KairosCommandWithContext[] = [] + + const macroKeys = getAllKeysString(oldMacros, newMacros) + for (const macroKey of macroKeys) { + const newMacro = newMacros[macroKey] + const oldMacro = oldMacros[macroKey] + + const macroRef = newMacro?.ref || oldMacro?.ref + if (!macroRef) continue // No macro to diff + + // Check if active state changed + const oldActive = oldMacro?.state.active + const newActive = newMacro?.state.active + + if (oldActive !== newActive && newActive !== undefined && newActive !== KairosMacroActiveState.UNCHANGED) { + commands.push({ + timelineObjId: newMacro?.timelineObjIds.join(' & ') ?? '', + context: `macroKey=${macroKey} newMacro=${!!newMacro} oldMacro=${!!oldMacro}`, + command: { + type: 'macro', + macroName: macroKey, + macroRef: macroRef, + active: newActive === KairosMacroActiveState.PLAYING, + }, + }) + } + } + + return commands +} diff --git a/packages/timeline-state-resolver/src/integrations/kairos/diffState/lib.ts b/packages/timeline-state-resolver/src/integrations/kairos/diffState/lib.ts new file mode 100644 index 0000000000..0d8e13e44d --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/diffState/lib.ts @@ -0,0 +1,70 @@ +import { isEqual } from 'underscore' + +/** + * Iterates over top-level keys and returns an object with only the changed keys. + * Uses deep equality comparison for each value. + * @param oldObj + * @param newObj + * @returns + */ +export function diffObject( + oldObj: Partial | undefined, + newObj: Partial | undefined +): Partial | undefined { + if (!newObj) return undefined + + const diff: Partial = {} + let hasChange = false + + for (const key in newObj) { + const typedKey = key as keyof T + if (newObj[typedKey] !== undefined && !isEqual(newObj[typedKey], oldObj?.[typedKey])) { + hasChange = true + diff[typedKey] = newObj[typedKey] + } + } + + return hasChange ? diff : undefined +} + +/** + * Does a shallow comparison of two objects, + * returning an object with booleans where any changed values will result in a `true` value. + * @param oldObj + * @param newObj + * @returns + */ +export function diffObjectBoolean( + oldObj: Partial | undefined, + newObj: Partial | undefined +): { [P in keyof T]?: boolean } | undefined { + if (!newObj) return undefined + + const diff: { [P in keyof T]?: boolean } = {} + let hasChange = false + + for (const key in newObj) { + const typedKey = key as keyof T + if (!isEqual(newObj[key], oldObj?.[key])) { + hasChange = true + diff[typedKey] = true + } + } + + return hasChange ? diff : undefined +} + +function keyIsValid(key: string, oldObj: any, newObj: any) { + const oldVal = oldObj[key] + const newVal = newObj[key] + return (oldVal !== undefined && oldVal !== null) || (newVal !== undefined && newVal !== null) +} +export function getAllKeysString( + oldObj0: { [key: string]: V } | undefined, + newObj0: { [key: string]: V } | undefined +): string[] { + const oldObj = oldObj0 ?? {} + const newObj = newObj0 ?? {} + const rawKeys = Object.keys(oldObj).concat(Object.keys(newObj)) + return rawKeys.filter((v, i) => keyIsValid(v, oldObj, newObj) && rawKeys.indexOf(v) === i) +} diff --git a/packages/timeline-state-resolver/src/integrations/kairos/diffState/media-players.ts b/packages/timeline-state-resolver/src/integrations/kairos/diffState/media-players.ts new file mode 100644 index 0000000000..9fb6aca2ad --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/diffState/media-players.ts @@ -0,0 +1,338 @@ +import { MediaClipRef, MediaRamRecRef, MediaSoundRef } from 'kairos-connection' +import { TimelineObjectInstance } from 'superfly-timeline' +import { TimelineContentKairosPlayerState } from 'timeline-state-resolver-types' +import { KairosCommandWithContext } from '..' +import { + KairosClipPlayerCommand, + KairosRamRecPlayerCommand, + KairosSoundPlayerCommand, + KairosPlayerCommandMethod, + KairosImageStoreCommand, +} from '../commands' +import { KairosDeviceState, MappingOptions } from '../stateBuilder' +import { diffObjectBoolean, getAllKeysString } from './lib' + +export function diffMediaPlayers( + stateTime: number, + playerType: KairosClipPlayerCommand['type'] | KairosRamRecPlayerCommand['type'] | KairosSoundPlayerCommand['type'], + oldClipPlayers: Record, + newClipPlayers: Record +): KairosCommandWithContext[] { + const commands: KairosCommandWithContext[] = [] + + const playerIds = getAllKeysString(oldClipPlayers, newClipPlayers).map((v) => parseInt(v)) + for (const playerId of playerIds) { + if (isNaN(playerId)) continue + + /** New state */ + const newClipPlayer = newClipPlayers[playerId] + /** Old state */ + const oldClipPlayer = oldClipPlayers[playerId] + + const cpRef = newClipPlayer?.ref || oldClipPlayer?.ref + if (!cpRef) continue // No ClipPlayer to diff + + /** The properties to update on the ClipPlayer */ + const updateCmd: KairosClipPlayerCommand | KairosRamRecPlayerCommand | KairosSoundPlayerCommand = { + type: playerType, + playerId: playerId, + values: {}, + } + + if (!newClipPlayer && oldClipPlayer) { + // ClipPlayer obj was removed, stop it: + + const contextTimelineObjId = oldClipPlayer?.timelineObjIds.join(' & ') ?? '' + + const clearPlayerOnStop = + oldClipPlayer.state.content.clearPlayerOnStop ?? oldClipPlayer.state.mappingOptions.clearPlayerOnStop ?? false + + if (clearPlayerOnStop) { + commands.push({ + timelineObjId: contextTimelineObjId, + context: `key=${playerId} newClipPlayer=${!!newClipPlayer} oldClipPlayer=${!!oldClipPlayer}`, + command: { + ...updateCmd, + values: { + clip: null, // Clear the clip + }, + }, + }) + } else { + commands.push({ + timelineObjId: contextTimelineObjId, + context: `key=${playerId} newClipPlayer=${!!newClipPlayer} oldClipPlayer=${!!oldClipPlayer}`, + command: { + type: 'media-player:do', + playerId: playerId, + playerType: playerType, + command: 'stop', + }, + }) + } + } else if (newClipPlayer) { + const framerate = newClipPlayer.state.mappingOptions.framerate || 25 + + const oldState = getClipPlayerState(oldClipPlayer) + const newState = getClipPlayerState(newClipPlayer) + + const diff = diffObjectBoolean(oldState, newState) + + if (diff) { + /** The command to be sent to the ClipPlayer */ + let playerCommand: KairosPlayerCommandMethod['command'] | null = null + + if ( + diff.absoluteStartTime && + newState.absoluteStartTime !== undefined && + oldState?.absoluteStartTime !== undefined && + Math.abs(newState.absoluteStartTime - oldState.absoluteStartTime) < 100 + ) { + // If the absolute seek position diff is within 100ms, ignore it: + delete diff.absoluteStartTime + } + + if (updateCmd.type === 'clip-player' || updateCmd.type === 'ram-rec-player') { + // ( color and colorOverwrite only apply to clip-players and ram-rec-players ) + if (diff.color) { + updateCmd.values.color = newState.color + } + if (diff.colorOverwrite) { + updateCmd.values.colorOverwrite = newState.colorOverwrite + } + } + if (diff.repeat) { + updateCmd.values.repeat = newState.repeat ?? false + } + + if ( + // The clip has changed, trigger a play/stop command: + diff.clip || + // The seek position has changed, move the playhead: + diff.absoluteStartTime + ) { + // This will trigger a play command below: + newState.playing = newState.playing ?? false + diff.playing = true + } + + if (diff.playing && newState.playing) { + // Start playing the clip! + + // When starting playing, we sync the clip, position and repeat state: + if (newState.clip !== undefined) { + updateCmd.values.clip = newState.clip + } + if (newState.absoluteStartTime !== undefined) { + const newSeek = Math.max(0, -relativeTime(stateTime, newState.absoluteStartTime)) + + updateCmd.values.position = Math.floor((newSeek / 1000) * framerate) + } + updateCmd.values.repeat = newState.repeat ?? false + + playerCommand = 'play' + } else if (!newState.playing) { + // Stop the clip ( or load a paused clip ) + + if (newState.seek !== undefined) { + // Don't using the absolute time here, as we are pausing / seeking to a certain frame: + updateCmd.values.position = Math.floor((newState.seek / 1000) * framerate) + } + if (newState.clip) { + updateCmd.values.clip = newState.clip + } + // Stop / Pause the clip: + if (diff.playing) playerCommand = 'pause' + } + + if (!isEmptyObject(updateCmd.values)) { + commands.push({ + timelineObjId: newClipPlayer?.timelineObjIds.join(' & ') ?? '', + context: `key=${playerId} diff=${JSON.stringify(diff)}`, + command: updateCmd, + preliminary: playerCommand === 'play' ? 10 : 0, // Send this command a bit early if there's a Play command + }) + } + + if (playerCommand !== null) { + commands.push({ + timelineObjId: newClipPlayer?.timelineObjIds.join(' & ') ?? '', + context: `key=${playerId} diff=${JSON.stringify(diff)}`, + command: { + type: 'media-player:do', + playerId: playerId, + playerType: playerType, + command: playerCommand, + }, + }) + } + } + } + } + return commands +} + +export function diffMediaImageStore( + oldImageStores: KairosDeviceState['imageStores'], + newImageStores: KairosDeviceState['imageStores'] +): KairosCommandWithContext[] { + const commands: KairosCommandWithContext[] = [] + + // Note: An "Image Store" would have been better named "Still Player", because it is a player that plays still images + + const playerIds = getAllKeysString(oldImageStores, newImageStores).map((v) => parseInt(v)) + for (const playerId of playerIds) { + if (isNaN(playerId)) continue + + /** New state */ + const newImageStore = newImageStores[playerId] + /** Old state */ + const oldImageStore = oldImageStores[playerId] + + const cpRef = newImageStore?.ref || oldImageStore?.ref + if (!cpRef) continue // No ImageStore to diff + + if (!newImageStore && oldImageStore) { + // ImageStore obj was removed, stop it: + + const clearPlayerOnStop = + oldImageStore.state.content.imageStore.clearPlayerOnStop ?? + oldImageStore.state.mappingOptions.clearPlayerOnStop ?? + false + + if (clearPlayerOnStop) { + commands.push({ + timelineObjId: oldImageStore?.timelineObjIds.join(' & ') ?? '', + context: `key=${playerId} newImageStore=${!!newImageStore} oldImageStore=${!!oldImageStore}`, + command: { + type: 'image-store', + playerId: playerId, + values: { + clip: null, // Clear the clip + }, + }, + }) + } else { + // Do nothing, just leave it + } + } else if (newImageStore) { + /** The properties to update on the ImageStore */ + const updateCmd: KairosImageStoreCommand = { + type: 'image-store', + playerId: playerId, + values: {}, + } + + const oldState = oldImageStore?.state.content.imageStore + const newState = newImageStore?.state.content.imageStore + + const diff = diffObjectBoolean(oldState, newState) + + if (diff) { + if (diff.advancedResolutionControl && newState.advancedResolutionControl !== undefined) { + updateCmd.values.advancedResolutionControl = newState.advancedResolutionControl + } + if (diff.clip) { + updateCmd.values.clip = newState.clip ?? null + } + if (diff.color && newState.color !== undefined) { + updateCmd.values.color = newState.color + } + if (diff.colorOverwrite && newState.colorOverwrite !== undefined) { + updateCmd.values.colorOverwrite = newState.colorOverwrite + } + if (diff.dissolve && newState.dissolve !== undefined) { + updateCmd.values.dissolveEnabled = newState.dissolve.enabled ?? false + updateCmd.values.dissolveMode = newState.dissolve.mode + updateCmd.values.dissolveTime = newState.dissolve.duration + } + if (diff.removeSourceAlpha && newState.removeSourceAlpha !== undefined) { + updateCmd.values.removeSourceAlpha = newState.removeSourceAlpha + } + if (diff.resolution && newState.resolution !== undefined) { + updateCmd.values.resolution = newState.resolution + } + if (diff.resolutionX && newState.resolutionX !== undefined) { + updateCmd.values.resolutionX = newState.resolutionX + } + if (diff.resolutionY && newState.resolutionY !== undefined) { + updateCmd.values.resolutionY = newState.resolutionY + } + if (diff.scaleMode && newState.scaleMode !== undefined) { + updateCmd.values.scaleMode = newState.scaleMode + } + + if (!isEmptyObject(updateCmd.values)) { + commands.push({ + timelineObjId: newImageStore?.timelineObjIds.join(' & ') ?? '', + context: `key=${playerId} diff=${JSON.stringify(diff)}`, + command: updateCmd, + }) + } + } + } + } + return commands +} + +type MediaPlayerAny = TimelineContentKairosPlayerState +type MediaPlayerOuterState = { + ref: number + state: { content: MediaPlayerAny; instance: TimelineObjectInstance; mappingOptions: MappingOptions } + timelineObjIds: string[] +} +type MediaPlayerInnerState = MediaPlayerAny & { + /** Unix timestamp for at what point in time the clip starts to play */ + absoluteStartTime: number | undefined +} + +function isEmptyObject(obj: object | undefined): boolean { + if (!obj) return true + return Object.keys(obj).length === 0 +} + +function absoluteTime(instance: TimelineObjectInstance, relativeTime: undefined): undefined +function absoluteTime(instance: TimelineObjectInstance, relativeTime: number): number +function absoluteTime(instance: TimelineObjectInstance, relativeTime: number | undefined): number | undefined +function absoluteTime(instance: TimelineObjectInstance, relativeTime: number | undefined): number | undefined { + if (relativeTime === undefined) return undefined + return instance.start + relativeTime +} +function relativeTime(nowTime: number, absoluteTime: undefined): undefined +function relativeTime(nowTime: number, absoluteTime: number): number +function relativeTime(nowTime: number, absoluteTime: number | undefined): number | undefined +function relativeTime(nowTime: number, absoluteTime: number | undefined): number | undefined { + if (absoluteTime === undefined) return undefined + return absoluteTime - nowTime +} + +function getClipPlayerState(mediaPlayer: MediaPlayerOuterState | undefined): MediaPlayerInnerState { + if (mediaPlayer === undefined) + return { + absoluteStartTime: undefined, + } + + const seek = mediaPlayer.state.content.seek + const clipPlayerState: MediaPlayerInnerState = { + ...mediaPlayer.state.content, + absoluteStartTime: absoluteTime(mediaPlayer.state.instance, seek !== undefined ? -seek : undefined), + } + + if (clipPlayerState.playing === undefined) { + clipPlayerState.playing = true // defaults to true + } + + if (clipPlayerState.repeat === true) { + // `.repeat` is defined as: + // > If this is true, the actual frame position is not guaranteed (ie seek is ignored). + delete clipPlayerState.seek + delete clipPlayerState.absoluteStartTime + } + + if (clipPlayerState.playing === false) { + // If not playing, the absolute seek position is not relevant + delete clipPlayerState.absoluteStartTime + } + + return clipPlayerState +} diff --git a/packages/timeline-state-resolver/src/integrations/kairos/index.ts b/packages/timeline-state-resolver/src/integrations/kairos/index.ts new file mode 100644 index 0000000000..78f7120577 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/index.ts @@ -0,0 +1,141 @@ +import { + DeviceStatus, + StatusCode, + KairosOptions, + Mappings, + TSRTimelineContent, + SomeMappingKairos, + KairosDeviceTypes, + KairosActionMethods, +} from 'timeline-state-resolver-types' +// eslint-disable-next-line node/no-missing-import +import { KairosConnection } from 'kairos-connection' +import type { Device, DeviceContextAPI, CommandWithContext, DeviceTimelineState } from 'timeline-state-resolver-api' +import { KairosDeviceState, KairosStateBuilder } from './stateBuilder' +import { diffKairosStates } from './diffState' +import { sendCommand, type KairosCommandAny } from './commands' +import { getActions } from './actions' +import { KairosRamLoader } from './lib/kairosRamLoader' + +export type KairosCommandWithContext = CommandWithContext + +/** + * This is a wrapper for the Kairos Device. Commands to any and all kairos devices will be sent through here. + */ +export class KairosDevice implements Device { + private readonly _kairos: KairosConnection + private readonly _kairosRamLoader: KairosRamLoader + + readonly actions: KairosActionMethods + + constructor(public context: DeviceContextAPI) { + this._kairos = new KairosConnection() + this._kairosRamLoader = new KairosRamLoader(this._kairos, context) + this.actions = getActions(this._kairos) + } + + /** + * Initiates the connection with the KAIROS through the kairos-connection lib + * and initiates Kairos State lib. + */ + async init(options: KairosOptions): Promise { + this._kairos.on('disconnect', () => { + this._connectionChanged() + }) + this._kairos.on('error', (e) => this.context.logger.error('Kairos', e)) + this._kairos.on('warn', (e) => this.context.logger.warning(`Kairos: ${e?.message ?? e}`)) + + this._kairos.on('reset', () => { + this.context.resetResolver() + this._connectionChanged() + }) + + this._kairos.on('connect', () => { + this._connectionChanged() + + // Do a state diff to at least send all the commands we know about + this.context.resetState().catch((e) => this.context.logger.error('Error resetting kairos state', new Error(e))) + }) + + // Start the connection, without waiting + this._kairos.connect(options.host, options.port) + + return true + } + /** + * Safely terminate everything to do with this device such that it can be + * garbage collected. + */ + async terminate(): Promise { + this._kairos.disconnect() + this._kairos.discard() + this._kairos.removeAllListeners() + } + + get connected(): boolean { + return this._kairos.connected + } + + /** + * Convert a timeline state into an Kairos state. + * @param timelineState The state to be converted + */ + convertTimelineStateToDeviceState( + timelineState: DeviceTimelineState, + mappings: Mappings + ): KairosDeviceState { + const deviceState = KairosStateBuilder.fromTimeline(timelineState, mappings) + + return deviceState + } + + /** + * Check status and return it with useful messages appended. + */ + public getStatus(): Omit { + if (!this.connected) { + return { + statusCode: StatusCode.BAD, + messages: [`Kairos disconnected`], + } + } else { + return { + statusCode: StatusCode.GOOD, + messages: [], + } + } + } + + /** + * Compares the new timeline-state with the old one, and generates commands to account for the difference + * @param oldKairosState + * @param newKairosState + */ + diffStates( + oldKairosState: KairosDeviceState | undefined, + newKairosState: KairosDeviceState, + mappings: Mappings + ): Array { + // Skip diffing if not connected, a resolverReset will be fired upon reconnection + if (!this.connected) return [] + + return diffKairosStates(oldKairosState, newKairosState, mappings) + } + + async sendCommand(command: KairosCommandWithContext): Promise { + this.context.logger.debug(command) + + // Skip attempting send if not connected + if (!this.connected) return + + try { + await sendCommand(this._kairos, this._kairosRamLoader, command.command) + } catch (error: any) { + this.context.commandError(error, command) + } + } + + private _connectionChanged() { + this.context.connectionChanged(this.getStatus()) + } +} diff --git a/packages/timeline-state-resolver/src/integrations/kairos/lib/kairosRamLoader.ts b/packages/timeline-state-resolver/src/integrations/kairos/lib/kairosRamLoader.ts new file mode 100644 index 0000000000..bf56a67b7d --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/lib/kairosRamLoader.ts @@ -0,0 +1,111 @@ +import { + AnySourceRef, + isRef, + KairosConnection, + MediaRamRecRef, + MediaStatus, + MediaStillRef, + pathToRef, + refToPath, +} from 'kairos-connection' +import { KairosDeviceState } from '../stateBuilder' +import { DeviceContextAPI } from 'timeline-state-resolver-api' + +export class KairosRamLoader { + private debounceTrackLoadRAM = new Set() + + constructor(private kairos: KairosConnection, private context: DeviceContextAPI) {} + + async ensureRAMLoaded( + playerIdentifier: number | string, + source: AnySourceRef | string, + + afterLoadSetModifiedStateCallback: (currentState: KairosDeviceState) => KairosDeviceState | false + ): Promise { + // This method is called to ensure that a ramrec/still is loaded into RAM. + // If the ramrec/still is not loaded into RAM it cannot be used. + + const clipRef = typeof source === 'string' ? pathToRef(source) : source + if (!isRef(clipRef) || (clipRef.realm !== 'media-still' && clipRef.realm !== 'media-ramrec')) { + this.context.logger.error( + 'KairosRamLoader', + new Error(`KairosRamLoader: Unsupported clip reference for RAM loading: ${JSON.stringify(source)}`) + ) + return + } + + const identifier = clipRef.realm === 'media-ramrec' ? `RamRec ${refToPath(clipRef)}` : `Still ${refToPath(clipRef)}` + + // Step 1: Check that the clip exists: + const media = + clipRef.realm === 'media-ramrec' + ? await this.kairos.getMediaRamRec(clipRef) + : await this.kairos.getMediaStill(clipRef) + if (!media) throw new Error(`Cannot load ${identifier}: clip not found`) + + if (media.status === MediaStatus.ERROR) throw new Error(`Cannot load ${identifier}: status is ERROR`) + + if (media.status === MediaStatus.LOAD && media.loadProgress === 1) { + // Is already loaded, OK + return + } + + // Run the rest asynchronously, to not block commands (since we execute commands sequentially): + Promise.resolve() + .then(async () => { + // Step 2: Load the clip into RAM + if (media.status === MediaStatus.NOT_LOADED) { + if (clipRef.realm === 'media-ramrec') { + await this.kairos.updateMediaRamRec(clipRef, { + status: MediaStatus.LOAD, // Load the ramrec into RAM + }) + } else { + await this.kairos.updateMediaStill(clipRef, { + status: MediaStatus.LOAD, // Load the still into RAM + }) + } + } + + // Ensure that we're not already waiting for this clip to load: + const debounceKey = `${identifier}_${playerIdentifier}` + if (this.debounceTrackLoadRAM.has(debounceKey)) return // Already waiting for this clip to load + this.debounceTrackLoadRAM.add(debounceKey) + + try { + // Step 3: When the clip has been loaded, trigger a re-run of diffState so that it'll be loaded into the RAM player: + // (Note: we must not manually load it after a delay, as the timeline state might have changed in the meantime.) + await this.waitForLoadStatus(clipRef) + } finally { + this.debounceTrackLoadRAM.delete(debounceKey) + } + + // Modify the current device state to reflect that the clip is not loaded yet. + // Upon re-triggering diffState, it'll attempt to load the clip again. + + await this.context.setModifiedState(afterLoadSetModifiedStateCallback) + // ^ This will cause TSR to re-run the diffState and thus try to load the clip again. + }) + .catch((e) => this.context.logger.error(`Error while waiting for ramrec load: ${e}`, e)) + } + + async waitForLoadStatus(clipRef: MediaRamRecRef | MediaStillRef, maxWait = 60000): Promise { + const startTime = Date.now() + while (Date.now() - startTime < maxWait) { + const clip = + clipRef.realm === 'media-ramrec' + ? await this.kairos.getMediaRamRec(clipRef) + : await this.kairos.getMediaStill(clipRef) + + if (clip?.status === MediaStatus.LOAD && clip.loadProgress === 1) { + return + } else { + await this.sleep(2000) + } + } + throw new Error(`Timeout waiting for media ${refToPath(clipRef)} to load`) + } + + async sleep(duration = 100): Promise { + return new Promise((resolve) => setTimeout(resolve, duration)) + } +} diff --git a/packages/timeline-state-resolver/src/integrations/kairos/stateBuilder.ts b/packages/timeline-state-resolver/src/integrations/kairos/stateBuilder.ts new file mode 100644 index 0000000000..25c494d4f8 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/stateBuilder.ts @@ -0,0 +1,428 @@ +import { + Mapping, + SomeMappingKairos, + DeviceType, + MappingKairosType, + TimelineContentTypeKairos, + Mappings, + TSRTimelineContent, + TimelineContentKairosScene, + MappingKairosScene, + TimelineContentKairosSceneLayer, + MappingKairosSceneLayer, + TimelineContentKairosMacros, + TimelineContentKairosAux, + TimelineContentKairosClipPlayer, + TimelineContentKairosRamRecPlayer, + TimelineContentKairosSoundPlayer, + TimelineContentKairosImageStore, + MappingKairosAux, + MappingKairosClipPlayer, + MappingKairosRamRecPlayer, + MappingKairosImageStore, + MappingKairosSoundPlayer, + TimelineContentKairosPlayerState, + TimelineContentKairosSceneSnapshotInfo, + KairosMacroActiveState, + TimelineContentKairosMacroInfo, +} from 'timeline-state-resolver-types' +import { assertNever } from '../../lib' +import { + AuxRef, + MediaClipRef, + MediaSoundRef, + MediaRamRecRef, + refAuxName, + refScene, + refSceneLayer, + refToPath, + SceneLayerRef, + UpdateSceneLayerObject, + UpdateSceneObject, + type SceneRef, + SceneSnapshotRef, + refSceneSnapshot, + UpdateSceneSnapshotObject, + MacroRef, + refMacro, + // eslint-disable-next-line node/no-missing-import +} from 'kairos-connection' +import { TimelineObjectInstance } from 'superfly-timeline' +import { DeviceTimelineState, DeviceTimelineStateObject } from 'timeline-state-resolver-api' + +export interface KairosDeviceState { + stateTime: number + scenes: Record; timelineObjIds: string[] } | undefined> + sceneSnapshots: Record< + string, + | { + ref: SceneSnapshotRef + state: { active: boolean; properties: Partial } + timelineObjIds: string[] + } + | undefined + > + sceneLayers: Record< + string, + { ref: SceneLayerRef; state: Partial; timelineObjIds: string[] } | undefined + > + aux: Record + macros: Record< + string, + { ref: MacroRef; state: { active: KairosMacroActiveState }; timelineObjIds: string[] } | undefined + > + clipPlayers: Record< + number, + | { + ref: number + state: { + content: TimelineContentKairosPlayerState + instance: TimelineObjectInstance + mappingOptions: MappingOptions + } + timelineObjIds: string[] + } + | undefined + > + ramRecPlayers: Record< + number, + | { + ref: number + state: { + content: TimelineContentKairosPlayerState + instance: TimelineObjectInstance + mappingOptions: MappingOptions + } + timelineObjIds: string[] + } + | undefined + > + imageStores: Record< + number, + | { + ref: number + state: { + content: TimelineContentKairosImageStore + mappingOptions: MappingKairosImageStore + } + + timelineObjIds: string[] + } + | undefined + > + soundPlayers: Record< + number, + | { + ref: number + state: { + content: TimelineContentKairosPlayerState + instance: TimelineObjectInstance + mappingOptions: MappingOptions + } + timelineObjIds: string[] + } + | undefined + > +} + +export class KairosStateBuilder { + // Start out with default state: + readonly #deviceState: KairosDeviceState = { + stateTime: 0, + scenes: {}, + sceneSnapshots: {}, + sceneLayers: {}, + aux: {}, + macros: {}, + clipPlayers: {}, + ramRecPlayers: {}, + imageStores: {}, + soundPlayers: {}, + } + + public static fromTimeline( + timelineState: DeviceTimelineState, + mappings: Mappings + ): KairosDeviceState { + const builder = new KairosStateBuilder() + + // For every layer, augment the state + for (const tlObject of timelineState.objects) { + const content = tlObject.content + + const mapping = mappings[tlObject.layer] as Mapping | undefined + + if (mapping && content.deviceType === DeviceType.KAIROS) { + switch (mapping.options.mappingType) { + case MappingKairosType.Scene: + if (content.type === TimelineContentTypeKairos.SCENE) { + builder._applyScene(mapping.options, content, tlObject.id) + } + break + case MappingKairosType.SceneLayer: + if (content.type === TimelineContentTypeKairos.SCENE_LAYER) { + builder._applySceneLayer(mapping.options, content, tlObject.id) + } + break + case MappingKairosType.Aux: + if (content.type === TimelineContentTypeKairos.AUX) { + builder._applyAux(mapping.options, content, tlObject.id) + } + break + case MappingKairosType.Macro: + if (content.type === TimelineContentTypeKairos.MACROS) { + builder._applyMacro(content, tlObject.id) + } + break + case MappingKairosType.ClipPlayer: + if (content.type === TimelineContentTypeKairos.CLIP_PLAYER) { + builder._applyClipPlayer(mapping.options, content, tlObject) + } + break + case MappingKairosType.RamRecPlayer: + if (content.type === TimelineContentTypeKairos.RAMREC_PLAYER) { + builder._applyRamRecPlayer(mapping.options, content, tlObject) + } + break + case MappingKairosType.ImageStore: + if (content.type === TimelineContentTypeKairos.IMAGE_STORE) { + builder._applyImageStore( + mapping.options, + { + content, + mappingOptions: mapping.options, + }, + tlObject.id + ) + } + break + case MappingKairosType.SoundPlayer: + if (content.type === TimelineContentTypeKairos.SOUND_PLAYER) { + builder._applySoundPlayer(mapping.options, content, tlObject) + } + break + default: + assertNever(mapping.options) + break + } + } + } + builder.#deviceState.stateTime = timelineState.time + + return builder.#deviceState + } + + private _mergeState( + oldState: + | { + ref: TRef + state: TState + timelineObjIds: string[] + } + | null + | undefined, + ref: TRef, + state: TState, + timelineObjId: string + ): { ref: TRef; state: TState; timelineObjIds: string[] } { + return { + ref: ref, + state: { ...oldState?.state, ...state }, + timelineObjIds: oldState ? [...oldState.timelineObjIds, timelineObjId] : [timelineObjId], + } + } + + private _applyScene(mapping: MappingKairosScene, content: TimelineContentKairosScene, timelineObjId: string): void { + if (!mapping.sceneName || mapping.sceneName.length === 0) return + + const sceneRef = refScene(mapping.sceneName) + const sceneId = refToPath(sceneRef) + + // Perform a simple merge of the content into the state + if (Object.keys(content.scene).length > 0) { + this.#deviceState.scenes[sceneId] = this._mergeState( + this.#deviceState.scenes[sceneId], + sceneRef, + content.scene, + timelineObjId + ) + } + + // Handle snapshots + for (const snapshotInfo of Object.values(content.recallSnapshots)) { + if (!snapshotInfo) continue + + const snapshotRef = refSceneSnapshot(sceneRef, snapshotInfo.ref) + const snapshotId = refToPath(snapshotRef) + + this.#deviceState.sceneSnapshots[snapshotId] = this._mergeState( + this.#deviceState.sceneSnapshots[snapshotId], + snapshotRef, + { + active: snapshotInfo.active, + properties: snapshotInfo.properties, + }, + timelineObjId + ) + } + } + + private _applySceneLayer( + mapping: MappingKairosSceneLayer, + content: TimelineContentKairosSceneLayer, + timelineObjId: string + ): void { + if (!mapping.sceneName || mapping.sceneName.length === 0) return + if (!mapping.layerName || mapping.layerName.length === 0) return + + const sceneLayerRef = refSceneLayer(refScene(mapping.sceneName), mapping.layerName) + const sceneLayerId = refToPath(sceneLayerRef) + + // Perform a simple merge of the content into the state + this.#deviceState.sceneLayers[sceneLayerId] = this._mergeState( + this.#deviceState.sceneLayers[sceneLayerId], + sceneLayerRef, + content.sceneLayer, + timelineObjId + ) + } + + private _applyAux(mapping: MappingKairosAux, content: TimelineContentKairosAux, timelineObjId: string): void { + if (!mapping.auxName || mapping.auxName.length === 0) return + + const auxRef = refAuxName(mapping.auxName) + const auxId = refToPath(auxRef) + + // Perform a simple merge of the content into the state + this.#deviceState.aux[auxId] = this._mergeState(this.#deviceState.aux[auxId], auxRef, content, timelineObjId) + } + + private _applyMacro(content: TimelineContentKairosMacros, timelineObjId: string): void { + for (const macroInfo of Object.values(content.macros)) { + if (!macroInfo) continue + + const macroRef = refMacro(macroInfo.ref) + const macroId = refToPath(macroRef) + + this.#deviceState.macros[macroId] = this._mergeState( + this.#deviceState.macros[macroId], + macroRef, + { + active: macroInfo.active, + }, + timelineObjId + ) + } + } + + private _applyClipPlayer( + mapping: MappingKairosClipPlayer, + content: TimelineContentKairosClipPlayer, + timelineObj: DeviceTimelineStateObject + ): void { + if (typeof mapping.playerId !== 'number' || mapping.playerId < 1) return + + const playerId = mapping.playerId + + this.#deviceState.clipPlayers[playerId] = this._mergeState( + this.#deviceState.clipPlayers[playerId], + playerId, + { + content: patchPlayerStateForLookahead(content.clipPlayer, timelineObj.isLookahead), + instance: timelineObj.instance, + mappingOptions: { + framerate: mapping.framerate, + clearPlayerOnStop: mapping.clearPlayerOnStop, + }, + }, + timelineObj.id + ) + } + + private _applyRamRecPlayer( + mapping: MappingKairosRamRecPlayer, + content: TimelineContentKairosRamRecPlayer, + timelineObj: DeviceTimelineStateObject + ): void { + if (typeof mapping.playerId !== 'number' || mapping.playerId < 1) return + + const playerId = mapping.playerId + + this.#deviceState.ramRecPlayers[playerId] = this._mergeState( + this.#deviceState.ramRecPlayers[playerId], + playerId, + { + content: patchPlayerStateForLookahead(content.ramRecPlayer, timelineObj.isLookahead), + instance: timelineObj.instance, + mappingOptions: { + framerate: mapping.framerate, + clearPlayerOnStop: mapping.clearPlayerOnStop, + }, + }, + timelineObj.id + ) + } + + private _applyImageStore( + mapping: MappingKairosImageStore, + state: { + mappingOptions: MappingKairosImageStore + content: TimelineContentKairosImageStore + }, + timelineObjId: string + ): void { + if (typeof mapping.playerId !== 'number' || mapping.playerId < 1) return + + const playerId = mapping.playerId + + this.#deviceState.imageStores[playerId] = this._mergeState( + this.#deviceState.imageStores[playerId], + playerId, + state, + timelineObjId + ) + } + + private _applySoundPlayer( + mapping: MappingKairosSoundPlayer, + content: TimelineContentKairosSoundPlayer, + timelineObj: DeviceTimelineStateObject + ): void { + if (typeof mapping.playerId !== 'number' || mapping.playerId < 1) return + + const playerId = mapping.playerId + + this.#deviceState.soundPlayers[playerId] = this._mergeState( + this.#deviceState.soundPlayers[playerId], + playerId, + { + content: patchPlayerStateForLookahead(content.soundPlayer, timelineObj.isLookahead), + instance: timelineObj.instance, + mappingOptions: { + framerate: mapping.framerate, + clearPlayerOnStop: mapping.clearPlayerOnStop, + }, + }, + timelineObj.id + ) + } +} +export type MappingOptions = { + framerate?: number + clearPlayerOnStop?: boolean +} + +function patchPlayerStateForLookahead( + playerState: TimelineContentKairosPlayerState, + isLookahead: boolean | undefined +): TimelineContentKairosPlayerState { + if (!isLookahead) return playerState + + return { + ...playerState, + // Should always be paused in lookahead + playing: false, + // If no seek, enforce it to the start to allow back to back objects with the same media + seek: playerState.seek ?? 0, + } +} diff --git a/packages/timeline-state-resolver/src/integrations/pharos/__tests__/pharosAPI.spec.ts b/packages/timeline-state-resolver/src/integrations/pharos/__tests__/pharosAPI.spec.ts index e37b7d836c..3dbdddb6c2 100644 --- a/packages/timeline-state-resolver/src/integrations/pharos/__tests__/pharosAPI.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/pharos/__tests__/pharosAPI.spec.ts @@ -18,7 +18,7 @@ import { Protocol, } from '../connection' import { getMockCall } from '../../../__tests__/lib' -import * as WebSocket from '../../../__mocks__/ws' +import WebSocket from '../../../__mocks__/ws' import got from '../../../__mocks__/got' import { OptionsOfTextResponseBody, Response } from 'got' diff --git a/packages/timeline-state-resolver/src/integrations/pharos/connection.ts b/packages/timeline-state-resolver/src/integrations/pharos/connection.ts index 27e99eaf59..ce08731632 100644 --- a/packages/timeline-state-resolver/src/integrations/pharos/connection.ts +++ b/packages/timeline-state-resolver/src/integrations/pharos/connection.ts @@ -1,4 +1,4 @@ -import * as WebSocket from 'ws' +import WebSocket from 'ws' import { EventEmitter } from 'events' import got, { GotRequestFunction } from 'got' import * as _ from 'underscore' diff --git a/packages/timeline-state-resolver/src/integrations/sofieChef/__tests__/sofieChef.spec.ts b/packages/timeline-state-resolver/src/integrations/sofieChef/__tests__/sofieChef.spec.ts index e874cff0cc..9087abc63f 100644 --- a/packages/timeline-state-resolver/src/integrations/sofieChef/__tests__/sofieChef.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/sofieChef/__tests__/sofieChef.spec.ts @@ -1,7 +1,7 @@ import { SofieChefDevice } from '..' import { StatusCode } from 'timeline-state-resolver-types' import { MockTime } from '../../../__tests__/mockTime' -import * as WebSocket from '../../../__mocks__/ws' +import WebSocket from '../../../__mocks__/ws' import { literal } from '../../../lib' import { SendWSMessageAny, SendWSMessageType, StatusCode as ChefStatusCode } from '../api' import { getDeviceContext } from '../../__tests__/testlib' diff --git a/packages/timeline-state-resolver/src/integrations/sofieChef/index.ts b/packages/timeline-state-resolver/src/integrations/sofieChef/index.ts index 774c91671c..3bc6831d08 100644 --- a/packages/timeline-state-resolver/src/integrations/sofieChef/index.ts +++ b/packages/timeline-state-resolver/src/integrations/sofieChef/index.ts @@ -10,7 +10,7 @@ import { SofieChefDeviceTypes, SofieChefActions, } from 'timeline-state-resolver-types' -import * as WebSocket from 'ws' +import WebSocket from 'ws' import { ReceiveWSMessageAny, ReceiveWSMessageType, diff --git a/packages/timeline-state-resolver/src/integrations/tricaster/__tests__/index.spec.ts b/packages/timeline-state-resolver/src/integrations/tricaster/__tests__/index.spec.ts index 49176b6185..954344160f 100644 --- a/packages/timeline-state-resolver/src/integrations/tricaster/__tests__/index.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/tricaster/__tests__/index.spec.ts @@ -7,6 +7,7 @@ import { TimelineContentTriCasterME, Mapping, TSRTimelineContent, + TriCasterMixEffect, } from 'timeline-state-resolver-types' import { TriCasterDevice } from '..' import { TriCasterConnectionEvents, TriCasterConnection } from '../triCasterConnection' @@ -82,7 +83,12 @@ describe('TriCasterDevice', () => { content: { deviceType: DeviceType.TRICASTER, type: TimelineContentTypeTriCaster.ME, - me: { programInput: 'input2', previewInput: 'input3', transitionEffect: 5, transitionDuration: 20 } as any, + me: { + programInput: 'input2', + previewInput: 'input3', + transitionEffect: 5, + transitionDuration: 20, + } as TriCasterMixEffect, }, }), ], diff --git a/packages/timeline-state-resolver/src/integrations/tricaster/__tests__/triCasterTimelineStateConverter.spec.ts b/packages/timeline-state-resolver/src/integrations/tricaster/__tests__/triCasterTimelineStateConverter.spec.ts index fc3d3aefa6..f3179ed389 100644 --- a/packages/timeline-state-resolver/src/integrations/tricaster/__tests__/triCasterTimelineStateConverter.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/tricaster/__tests__/triCasterTimelineStateConverter.spec.ts @@ -8,6 +8,7 @@ import { TimelineContentTriCasterMatrixOutput, Mapping, SomeMappingTricaster, + TriCasterMixEffect, } from 'timeline-state-resolver-types' import { TriCasterTimelineStateConverter } from '../triCasterTimelineStateConverter' import { @@ -116,7 +117,7 @@ describe('TimelineStateConverter.getTriCasterStateFromTimelineState', () => { previewInput: 'input3', transitionEffect: 5, transitionDuration: 20, - } as any, + } as TriCasterMixEffect, }, }), wrapIntoResolvedInstance({ @@ -345,7 +346,7 @@ describe('TimelineStateConverter.getTriCasterStateFromTimelineState', () => { previewInput: 'input3', transitionEffect: 5, transitionDuration: 20, - } as any, + } as TriCasterMixEffect, }, }), wrapIntoResolvedInstance({ diff --git a/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts b/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts index 102ab509fd..8e33af1aa1 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts @@ -20,7 +20,7 @@ import { VMixState, VMixStateExtended, } from './vMixStateDiffer' -import * as deepMerge from 'deepmerge' +import deepMerge from 'deepmerge' import _ = require('underscore') const mappingPriority: { [k in MappingVmixType]: number } = { diff --git a/packages/timeline-state-resolver/src/integrations/websocketClient/connection.ts b/packages/timeline-state-resolver/src/integrations/websocketClient/connection.ts index aa07f77b2b..422cef0e7d 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketClient/connection.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketClient/connection.ts @@ -1,4 +1,4 @@ -import * as WebSocket from 'ws' +import WebSocket from 'ws' import { DeviceStatus, StatusCode, WebsocketClientOptions } from 'timeline-state-resolver-types' export class WebSocketConnection { diff --git a/packages/timeline-state-resolver/src/lib.ts b/packages/timeline-state-resolver/src/lib.ts index 22112ffe73..bb8ccc7654 100644 --- a/packages/timeline-state-resolver/src/lib.ts +++ b/packages/timeline-state-resolver/src/lib.ts @@ -8,6 +8,14 @@ export function literal(o: T) { return o } +/** + * Make all optional properties be required and `| undefined` + * This is useful to ensure that no property is missed, when manually converting between types, but allowing fields to be undefined + */ +export type Complete = { + [P in keyof Required]: Pick extends Required> ? T[P] : T[P] | undefined +} + /** Deeply extend an object with some partial objects */ export function deepMerge(destination: T, source: PartialDeep): T { return deepmerge(destination, source) diff --git a/packages/timeline-state-resolver/src/manifest.ts b/packages/timeline-state-resolver/src/manifest.ts index 90b2b643dc..ff343307a9 100644 --- a/packages/timeline-state-resolver/src/manifest.ts +++ b/packages/timeline-state-resolver/src/manifest.ts @@ -26,6 +26,9 @@ import HttpWatcherMappings = require('./$schemas/generated/httpWatcher/mappings. import HyperdeckActions = require('./$schemas/generated/hyperdeck/actions.json') import HyperdeckOptions = require('./$schemas/generated/hyperdeck/options.json') import HyperdeckMappings = require('./$schemas/generated/hyperdeck/mappings.json') +import KairosActions = require('./$schemas/generated/kairos/actions.json') +import KairosOptions = require('./$schemas/generated/kairos/options.json') +import KairosMappings = require('./$schemas/generated/kairos/mappings.json') import LawoOptions = require('./$schemas/generated/lawo/options.json') import LawoMappings = require('./$schemas/generated/lawo/mappings.json') import MultiOscOptions = require('./$schemas/generated/multiOsc/options.json') @@ -110,6 +113,12 @@ export const builtinDeviceManifest: TSRManifest = { configSchema: JSON.stringify(HyperdeckOptions), mappingsSchemas: stringifyMappingSchema(HyperdeckMappings), }, + [DeviceType.KAIROS]: { + displayName: generateTranslation('Kairos'), + actions: KairosActions.actions.map(stringifyActionSchema), + configSchema: JSON.stringify(KairosOptions), + mappingsSchemas: stringifyMappingSchema(KairosMappings), + }, [DeviceType.LAWO]: { displayName: generateTranslation('Lawo'), configSchema: JSON.stringify(LawoOptions), diff --git a/packages/timeline-state-resolver/src/service/ConnectionManager.ts b/packages/timeline-state-resolver/src/service/ConnectionManager.ts index 3a4fa6ebb2..18f807747e 100644 --- a/packages/timeline-state-resolver/src/service/ConnectionManager.ts +++ b/packages/timeline-state-resolver/src/service/ConnectionManager.ts @@ -449,6 +449,7 @@ function createContainer( case DeviceType.TRICASTER: case DeviceType.VISCA_OVER_IP: case DeviceType.WEBSOCKET_CLIENT: + case DeviceType.KAIROS: case DeviceType.QUANTEL: { ensureIsImplementedAsService(deviceOptions.type) diff --git a/packages/timeline-state-resolver/src/service/DeviceInstance.ts b/packages/timeline-state-resolver/src/service/DeviceInstance.ts index aad1e117c2..da73a90dba 100644 --- a/packages/timeline-state-resolver/src/service/DeviceInstance.ts +++ b/packages/timeline-state-resolver/src/service/DeviceInstance.ts @@ -1,5 +1,5 @@ import { EventEmitter } from 'node:events' -import { actionNotFoundMessage } from '../lib' +import { actionNotFoundMessage, cloneDeep } from '../lib' import type { FinishedTrace, DeviceEntry, @@ -333,7 +333,16 @@ export class DeviceInstanceWrapper extends EventEmitter { await this._stateHandler.clearFutureStates() this.emit('resyncStates') }, + setModifiedState: async (cb: (currentState: DeviceState) => DeviceState | false) => { + const currentState = cloneDeep(this._stateHandler.getCurrentState()) + const newState = cb(currentState) + if (newState === false) return // false means no changes were made, and no resyncStates is necessary + + await this._stateHandler.setCurrentState(newState) + await this._stateHandler.clearFutureStates() + this.emit('resyncStates') + }, resetToState: async (state: any) => { await this._stateHandler.setCurrentState(state) await this._stateHandler.clearFutureStates() diff --git a/packages/timeline-state-resolver/src/service/devices.ts b/packages/timeline-state-resolver/src/service/devices.ts index e0b3da0010..825cb93110 100644 --- a/packages/timeline-state-resolver/src/service/devices.ts +++ b/packages/timeline-state-resolver/src/service/devices.ts @@ -20,6 +20,7 @@ import { TriCasterDevice } from '../integrations/tricaster' import { SingularLiveDevice } from '../integrations/singularLive' import { MultiOSCMessageDevice } from '../integrations/multiOsc' import { WebSocketClientDevice } from '../integrations/websocketClient' +import { KairosDevice } from '../integrations/kairos' export type ImplementedServiceDeviceTypes = | DeviceType.ABSTRACT @@ -42,6 +43,7 @@ export type ImplementedServiceDeviceTypes = | DeviceType.QUANTEL | DeviceType.VISCA_OVER_IP | DeviceType.WEBSOCKET_CLIENT + | DeviceType.KAIROS // TODO - move all device implementations here and remove the old Device classes export const DevicesDict: Record = { @@ -75,6 +77,12 @@ export const DevicesDict: Record = { deviceName: (deviceId: string) => 'Hyperdeck ' + deviceId, executionMode: () => 'salvo', }, + [DeviceType.KAIROS]: { + deviceClass: KairosDevice, + canConnect: true, + deviceName: (deviceId: string) => 'Kairos ' + deviceId, + executionMode: () => 'sequential', + }, [DeviceType.LAWO]: { deviceClass: LawoDevice, canConnect: true, diff --git a/packages/timeline-state-resolver/src/service/stateHandler.ts b/packages/timeline-state-resolver/src/service/stateHandler.ts index 6b7342641b..2cdb9c5e8c 100644 --- a/packages/timeline-state-resolver/src/service/stateHandler.ts +++ b/packages/timeline-state-resolver/src/service/stateHandler.ts @@ -161,6 +161,10 @@ export class StateHandler< await this.calculateNextStateChange() } + getCurrentState(): DeviceState | undefined { + return this.currentState?.deviceState + } + /** * This takes in a DeviceState and then updates the commands such that the device * will be put back into its intended state as designated by the timeline diff --git a/packages/timeline-state-resolver/tsconfig.build.json b/packages/timeline-state-resolver/tsconfig.build.json index 7cf3ba648a..e93540cc33 100755 --- a/packages/timeline-state-resolver/tsconfig.build.json +++ b/packages/timeline-state-resolver/tsconfig.build.json @@ -13,6 +13,9 @@ "types": ["node"], "composite": true, + "moduleResolution": "node16", + "esModuleInterop": true, + // Temporary overrides, to allow for gradual migration project "noImplicitAny": false, "resolveJsonModule": true, diff --git a/yarn.lock b/yarn.lock index 001b501094..0de4a2efea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8256,6 +8256,25 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"kairos-connection@npm:0.2.3": + version: 0.2.3 + resolution: "kairos-connection@npm:0.2.3" + dependencies: + kairos-lib: "npm:0.2.3" + tslib: "npm:^2.8.1" + checksum: 10c0/461801d6cc11bb6e6594fc2067b18c6dd9359d52afb56f854da995d209c3f4675d03f0c4eff1f24007a05a4ee40b6cd367aa1f6e473cafed1c5460fe3b4d0b1a + languageName: node + linkType: hard + +"kairos-lib@npm:0.2.3": + version: 0.2.3 + resolution: "kairos-lib@npm:0.2.3" + dependencies: + tslib: "npm:^2.8.1" + checksum: 10c0/d40347cc5f3b00a10acee99156b54e2fe742dc5d80def3749f5096b2c8da5b03491c7ac87172421684846debb13cbc4f1f2a3283debb6b95b3d7b94e64514db1 + languageName: node + linkType: hard + "keyv@npm:^4.0.0, keyv@npm:^4.5.3": version: 4.5.4 resolution: "keyv@npm:4.5.4" @@ -12272,6 +12291,7 @@ asn1@evs-broadcast/node-asn1: version: 0.0.0-use.local resolution: "timeline-state-resolver-types@workspace:packages/timeline-state-resolver-types" dependencies: + kairos-lib: "npm:0.2.3" tslib: "npm:^2.8.1" languageName: unknown linkType: soft @@ -12295,6 +12315,7 @@ asn1@evs-broadcast/node-asn1: hyperdeck-connection: "npm:2.0.1" jest-mock-extended: "npm:^4.0.0" json-schema-to-typescript: "npm:^15.0.4" + kairos-connection: "npm:0.2.3" klona: "npm:^2.0.6" obs-websocket-js: "npm:^5.0.7" osc: "npm:^2.4.5"