From 84b7bda00e1d92985729a328102a77ca7e7d377c Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 10 Sep 2025 16:27:46 +0100 Subject: [PATCH 01/21] feat: basic kairos device --- package.json | 2 +- .../tsconfig.build.json | 5 +- .../package.json | 1 + .../__snapshots__/index.spec.ts.snap | 4 + .../src/generated/device-options.ts | 7 + .../src/generated/index.ts | 4 + .../src/generated/kairos.ts | 114 ++++++ .../src/index.ts | 3 + .../src/integrations/kairos.ts | 157 ++++++++ .../tsconfig.build.json | 5 +- packages/timeline-state-resolver/package.json | 1 + .../src/__mocks__/ws.ts | 2 +- .../__snapshots__/index.spec.ts.snap | 4 + .../timeline-state-resolver/src/conductor.ts | 2 +- .../httpSend/__tests__/httpsend.spec.ts | 10 +- .../httpWatcher/__tests__/httpwatcher.spec.ts | 10 +- .../integrations/kairos/$schemas/actions.json | 115 ++++++ .../kairos/$schemas/mappings.json | 132 +++++++ .../integrations/kairos/$schemas/options.json | 21 ++ .../src/integrations/kairos/commands.ts | 143 ++++++++ .../src/integrations/kairos/diffState.ts | 241 ++++++++++++ .../src/integrations/kairos/index.ts | 181 +++++++++ .../src/integrations/kairos/stateBuilder.ts | 343 ++++++++++++++++++ .../pharos/__tests__/pharosAPI.spec.ts | 2 +- .../src/integrations/pharos/connection.ts | 2 +- .../sofieChef/__tests__/sofieChef.spec.ts | 2 +- .../src/integrations/sofieChef/index.ts | 2 +- .../tricaster/__tests__/index.spec.ts | 8 +- .../triCasterTimelineStateConverter.spec.ts | 15 +- .../vmix/vMixTimelineStateConverter.ts | 2 +- .../websocketClient/connection.ts | 2 +- packages/timeline-state-resolver/src/lib.ts | 8 + .../timeline-state-resolver/src/manifest.ts | 9 + .../src/service/ConnectionManager.ts | 1 + .../src/service/devices.ts | 8 + .../tsconfig.build.json | 3 + yarn.lock | 45 ++- 37 files changed, 1583 insertions(+), 33 deletions(-) create mode 100644 packages/timeline-state-resolver-types/src/generated/kairos.ts create mode 100644 packages/timeline-state-resolver-types/src/integrations/kairos.ts create mode 100644 packages/timeline-state-resolver/src/integrations/kairos/$schemas/actions.json create mode 100644 packages/timeline-state-resolver/src/integrations/kairos/$schemas/mappings.json create mode 100644 packages/timeline-state-resolver/src/integrations/kairos/$schemas/options.json create mode 100644 packages/timeline-state-resolver/src/integrations/kairos/commands.ts create mode 100644 packages/timeline-state-resolver/src/integrations/kairos/diffState.ts create mode 100644 packages/timeline-state-resolver/src/integrations/kairos/index.ts create mode 100644 packages/timeline-state-resolver/src/integrations/kairos/stateBuilder.ts diff --git a/package.json b/package.json index db66479a6f..9be7cf8229 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "ts-jest": "^29.2.4", "ts-node": "^8.10.2", "typedoc": "^0.23.28", - "typescript": "~4.9.5" + "typescript": "~5.1" }, "prettier": "@sofie-automation/code-standard-preset/.prettierrc.json", "packageManager": "yarn@3.5.0" diff --git a/packages/timeline-state-resolver-api/tsconfig.build.json b/packages/timeline-state-resolver-api/tsconfig.build.json index 61bc571ba3..a38fdb3c27 100644 --- a/packages/timeline-state-resolver-api/tsconfig.build.json +++ b/packages/timeline-state-resolver-api/tsconfig.build.json @@ -9,6 +9,9 @@ "*": ["./node_modules/*"], "{{PACKAGE-NAME}}": ["./src/index.ts"] }, - "types": ["node"] + "types": ["node"], + + "moduleResolution": "node16", + "esModuleInterop": true } } diff --git a/packages/timeline-state-resolver-types/package.json b/packages/timeline-state-resolver-types/package.json index eb10644824..4f7ccfbee9 100644 --- a/packages/timeline-state-resolver-types/package.json +++ b/packages/timeline-state-resolver-types/package.json @@ -83,6 +83,7 @@ "production" ], "dependencies": { + "kairos-connection": "0.0.2-nightly-main-20250909-152736-9c6b607.0", "tslib": "^2.6.3" }, "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 a7b60bc795..8cf36adf37 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..c75e08910d --- /dev/null +++ b/packages/timeline-state-resolver-types/src/generated/kairos.ts @@ -0,0 +1,114 @@ +/* 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 + mappingType: MappingKairosType.ClipPlayer +} + +export interface MappingKairosRamRecPlayer { + playerId: number + mappingType: MappingKairosType.RamRecPlayer +} + +export interface MappingKairosStillPlayer { + playerId: number + mappingType: MappingKairosType.StillPlayer +} + +export interface MappingKairosSoundPlayer { + playerId: number + mappingType: MappingKairosType.SoundPlayer +} + +export enum MappingKairosType { + Scene = 'scene', + SceneLayer = 'scene-layer', + Aux = 'aux', + Macro = 'macro', + ClipPlayer = 'clip-player', + RamRecPlayer = 'ram-rec-player', + StillPlayer = 'still-player', + SoundPlayer = 'sound-player', +} + +export type SomeMappingKairos = MappingKairosScene | MappingKairosSceneLayer | MappingKairosAux | MappingKairosMacro | MappingKairosClipPlayer | MappingKairosRamRecPlayer | MappingKairosStillPlayer | MappingKairosSoundPlayer + +export interface ListClipsPayload { + subDirectory?: string[] +} + +export type ListClipsResult = { + name: string[] + size: number + datetime?: number + frames: number + framerate: number +}[] + +export interface ListStillsPayload { + subDirectory?: string[] +} + +export type ListStillsResult = { + name: string[] + size: number + datetime?: number +}[] + +export interface PlayMacroPayload { + ref?: string[] +} + +export enum KairosActions { + ListClips = 'listClips', + ListStills = 'listStills', + PlayMacro = 'playMacro' +} +export interface KairosActionMethods { + [KairosActions.ListClips]: (payload: ListClipsPayload) => Promise>, + [KairosActions.ListStills]: (payload: ListStillsPayload) => Promise>, + [KairosActions.PlayMacro]: (payload: PlayMacroPayload) => 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 0b705178c3..40a533d6cc 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' @@ -121,6 +123,7 @@ export type TSRTimelineContent = | TimelineContentTelemetricsAny | TimelineContentTriCasterAny | TimelineContentWebSocketClientAny + | TimelineContentKairosAny /** * A simple key value store that can be referred to from the timeline objects 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..069325f051 --- /dev/null +++ b/packages/timeline-state-resolver-types/src/integrations/kairos.ts @@ -0,0 +1,157 @@ +import type { DeviceType } from '..' +import type { + RefPath, + MediaClipRef, + UpdateSceneObject, + UpdateSceneLayerObject, + UpdateClipPlayerObject, + MediaRamRecRef, + MediaSoundRef, + UpdateSceneSnapshotObject, + UpdateAuxObject, + // eslint-disable-next-line node/no-missing-import +} from 'kairos-connection' // TODO - this needs to be a types/lib package, not the connection package + +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', + STILL_PLAYER = 'still-player', + 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 + | TimelineContentKairosStillPlayer + | 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 TimelineContentKairosStillPlayer { + deviceType: DeviceType.KAIROS + type: TimelineContentTypeKairos.STILL_PLAYER + + // stillPlayer: TimelineContentKairosPlayerState +} +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 */ + 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] */ + 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 + + // 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 61bc571ba3..a38fdb3c27 100644 --- a/packages/timeline-state-resolver-types/tsconfig.build.json +++ b/packages/timeline-state-resolver-types/tsconfig.build.json @@ -9,6 +9,9 @@ "*": ["./node_modules/*"], "{{PACKAGE-NAME}}": ["./src/index.ts"] }, - "types": ["node"] + "types": ["node"], + + "moduleResolution": "node16", + "esModuleInterop": true } } diff --git a/packages/timeline-state-resolver/package.json b/packages/timeline-state-resolver/package.json index 8e79c97cf8..5584f966c3 100644 --- a/packages/timeline-state-resolver/package.json +++ b/packages/timeline-state-resolver/package.json @@ -103,6 +103,7 @@ "got": "^11.8.6", "hpagent": "^1.2.0", "hyperdeck-connection": "2.0.1", + "kairos-connection": "0.0.2-nightly-main-20250909-152736-9c6b607.0", "klona": "^2.0.6", "obs-websocket-js": "^5.0.6", "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 b95c8ebc7b..491bae98da 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 049e092332..0e1ed04f78 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 'eventemitter3' 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/httpSend/__tests__/httpsend.spec.ts b/packages/timeline-state-resolver/src/integrations/httpSend/__tests__/httpsend.spec.ts index 1522a3be38..bd7af90736 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 @@ -16,12 +16,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..ed9e3e75d2 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/$schemas/actions.json @@ -0,0 +1,115 @@ +{ + "$schema": "../../../$schemas/action-schema.json", + "actions": [ + { + "id": "listClips", + "name": "List clips in media folder", + "destructive": false, + "payload": { + "type": "object", + "properties": { + "subDirectory": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "result": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "array", + "items": { + "type": "string" + } + }, + "size": { + "type": "number" + }, + "datetime": { + "type": "number" + }, + "frames": { + "type": "number" + }, + "framerate": { + "type": "number" + } + }, + "required": [ + "name", + "size", + "dateTime", + "frames", + "framerate" + ], + "additionalProperties": false + } + } + }, + { + "id": "listStills", + "name": "List stills in media folder", + "destructive": false, + "payload": { + "type": "object", + "properties": { + "subDirectory": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "result": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "array", + "items": { + "type": "string" + } + }, + "size": { + "type": "number" + }, + "datetime": { + "type": "number" + } + }, + "required": [ + "name", + "size", + ], + "additionalProperties": false + } + } + }, + { + "id": "playMacro", + "name": "Play a macro", + "destructive": false, + "payload": { + "type": "object", + "properties": { + "ref": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "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..faec1f1b2f --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/$schemas/mappings.json @@ -0,0 +1,132 @@ +{ + "$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 + } + }, + "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 + } + }, + "required": ["playerId"], + "additionalProperties": false + }, + "still-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 + } + }, + "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 + } + }, + "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..7becfde2d7 --- /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": "CasparCG 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/commands.ts b/packages/timeline-state-resolver/src/integrations/kairos/commands.ts new file mode 100644 index 0000000000..8470c04b16 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/commands.ts @@ -0,0 +1,143 @@ +import type { + UpdateSceneObject, + UpdateSceneLayerObject, + UpdateAuxObject, + UpdateClipPlayerObject, + SceneRef, + SceneLayerRef, + AuxRef, + MacroRef, + SceneSnapshotRef, + KairosConnection, + // eslint-disable-next-line node/no-missing-import +} from 'kairos-connection' +import { assertNever } from '../../lib' + +export type KairosCommandAny = + | KairosSceneCommand + | KairosSceneRecallSnapshotCommand + | KairosSceneLayerCommand + | KairosAuxCommand + | KairosMacroCommand + | KairosClipPlayerCommand + | KairosRamRecPlayerCommand + | KairosStillPlayerCommand + | KairosSoundPlayerCommand + +export interface KairosSceneCommand { + type: 'scene' + + ref: SceneRef + + values: Partial +} + +export interface KairosSceneRecallSnapshotCommand { + type: 'scene-recall-snapshot' + + ref: SceneSnapshotRef + + snapshotName: string + active: boolean +} + +export interface KairosSceneLayerCommand { + type: 'scene-layer' + + ref: SceneLayerRef + + 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 KairosRamRecPlayerCommand { + type: 'ram-rec-player' + + playerId: number + + values: Partial +} + +export interface KairosStillPlayerCommand { + type: 'still-player' + + playerId: number + + values: null // TODO +} + +export interface KairosSoundPlayerCommand { + type: 'sound-player' + + playerId: number + + values: Partial +} + +export async function sendCommand(kairos: KairosConnection, 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) { + await kairos.sceneSnapshotRecall(command.ref) + } else { + await kairos.sceneSnapshotAbort(command.ref) + } + break + case 'scene-layer': + await kairos.updateSceneLayer(command.ref, command.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': + await kairos.updateClipPlayer(command.playerId, command.values) + break + case 'ram-rec-player': + await kairos.updateRamRecorder(command.playerId, command.values) + break + case 'still-player': + // TODO - not implemented + // await kairos.(command.ref, command.clip) + 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..803d439381 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts @@ -0,0 +1,241 @@ +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 { isEqual } from 'underscore' + +export function diffKairosStates( + oldKairosState: KairosDeviceState | undefined, + newKairosState: KairosDeviceState, + mappings: Mappings +): KairosCommandWithContext[] { + // Make sure there is something to diff against + oldKairosState = oldKairosState ?? KairosStateBuilder.fromTimeline({}, mappings) + + const commands: KairosCommandWithContext[] = [] + + // TODO - any concerns with temporal order (ie, cutting to/from a clip player before it has started/stopped playing?) + + // TODO - should this act more like atem-state where anything unset gets restored to a hardcoded 'default', or should it only set properties which are explicitly set/diff on the timeline? + // I almost did the latter, but then I realized that it would be hard to do anything. You would need to have a 'defaults' baseline object to set the default state, otherwise it wouldnt be + // repected when going between two timed objects. eg baseline: { a: 1, b: 2 }, object A: { a: 3, b: 3 } and object B: { a: 4 }. + // when going A -> B, it wouldnt know to set b to 2, because that wouldnt be present in the timeline. Depending what the property is, that may be fine or not. + + 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(...diffClipPlayers(oldKairosState.clipPlayers, newKairosState.clipPlayers)) + // commands.push(...diffRamRecPlayers(oldKairosState.ramRecPlayers, newKairosState.ramRecPlayers)) + // commands.push(...diffStillPlayers(oldKairosState.stillPlayers, newKairosState.stillPlayers)) + // commands.push(...diffSoundPlayers(oldKairosState.soundPlayers, newKairosState.soundPlayers)) + + 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, + 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 ?? false + const newActive = newSceneSnapshot?.state.active ?? false + + 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 +} + +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 +} + +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) +} +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/index.ts b/packages/timeline-state-resolver/src/integrations/kairos/index.ts new file mode 100644 index 0000000000..0c5229761b --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/index.ts @@ -0,0 +1,181 @@ +import { + DeviceStatus, + StatusCode, + KairosOptions, + Mappings, + Timeline, + TSRTimelineContent, + SomeMappingKairos, + KairosDeviceTypes, + KairosActionMethods, + KairosActions, +} from 'timeline-state-resolver-types' +// eslint-disable-next-line node/no-missing-import +import { KairosConnection } from 'kairos-connection' +import type { Device, DeviceContextAPI, CommandWithContext } from 'timeline-state-resolver-api' +import { KairosDeviceState, KairosStateBuilder } from './stateBuilder' +import { diffKairosStates } from './diffState' +import { sendCommand, type KairosCommandAny } from './commands' + +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 { + readonly actions: KairosActionMethods = { + [KairosActions.ListClips]: async () => { + throw new Error('Not implemented') + }, + [KairosActions.ListStills]: async () => { + throw new Error('Not implemented') + }, + [KairosActions.PlayMacro]: async () => { + throw new Error('Not implemented') + }, + } + + private readonly _kairos = new KairosConnection() + + constructor(protected context: DeviceContextAPI) { + // Nothing + } + + /** + * 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('stateChanged', (state) => { + // // the external device is communicating something changed, the tracker should be updated (and may fire a "blocked" event if the change is caused by someone else) + // updateFromKairosState((addr, addrState) => this.context.setAddressState(addr, addrState), state) // note - improvement can be to update depending on the actual paths that changed + + // // old stuff for connection statuses/events: + // this._onKairosStateChanged(state) + // }) + + this._kairos.on('reset', () => { + this.context.resetResolver() + this._connectionChanged() + }) + + this._kairos.on('connect', () => { + this._connectionChanged() + + // if (this._kairos.state) { + // // Do a state diff to get to the desired state + // this._protocolVersion = this._kairos.state.info.apiVersion + // this.context + // .resetToState(this._kairos.state) + // .catch((e) => this.context.logger.error('Error resetting kairos state', new Error(e))) + // } else { + // // 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() + } + + // private async resyncState(): Promise { + // this.context.resetResolver() + + // return { + // result: ActionExecutionResultCode.Ok, + // } + // } + + get connected(): boolean { + return this._kairos.connected + } + + /** + * Convert a timeline state into an Kairos state. + * @param timelineState The state to be converted + */ + convertTimelineStateToDeviceState( + timelineState: Timeline.TimelineState, + mappings: Mappings + ): KairosDeviceState { + const deviceState = KairosStateBuilder.fromTimeline(timelineState.layers, 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, command.command) + } catch (error: any) { + this.context.commandError(error, command) + } + } + + // applyAddressState(state: DeviceState, _address: string, addressState: AnyAddressState): void { + // applyAddressStateToKairosState(state, addressState) + // } + // diffAddressStates(state1: AnyAddressState, state2: AnyAddressState): boolean { + // return diffAddressStates(state1, state2) + // } + // addressStateReassertsControl(oldState: AnyAddressState | undefined, newState: AnyAddressState): boolean { + // return oldState?.controlValue !== newState.controlValue + // } + + private _connectionChanged() { + this.context.connectionChanged(this.getStatus()) + } +} 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..6ab6015d7e --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/stateBuilder.ts @@ -0,0 +1,343 @@ +import { + Mapping, + SomeMappingKairos, + DeviceType, + MappingKairosType, + TimelineContentTypeKairos, + Mappings, + TSRTimelineContent, + Timeline, + TimelineContentKairosScene, + MappingKairosScene, + TimelineContentKairosSceneLayer, + MappingKairosSceneLayer, + TimelineContentKairosMacros, + TimelineContentKairosAux, + TimelineContentKairosClipPlayer, + TimelineContentKairosRamRecPlayer, + TimelineContentKairosSoundPlayer, + TimelineContentKairosStillPlayer, + MappingKairosAux, + MappingKairosClipPlayer, + MappingKairosRamRecPlayer, + MappingKairosStillPlayer, + 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' + +export interface KairosDeviceState { + 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: TimelineContentKairosPlayerState; timelineObjIds: string[] } | undefined + > + ramRecPlayers: Record< + number, + { ref: number; state: TimelineContentKairosPlayerState; timelineObjIds: string[] } | undefined + > + stillPlayers: Record< + number, + { ref: number; state: TimelineContentKairosStillPlayer; timelineObjIds: string[] } | undefined + > + soundPlayers: Record< + number, + { ref: number; state: TimelineContentKairosPlayerState; timelineObjIds: string[] } | undefined + > +} + +export class KairosStateBuilder { + // Start out with default state: + readonly #deviceState: KairosDeviceState = { + scenes: {}, + sceneSnapshots: {}, + sceneLayers: {}, + aux: {}, + macros: {}, + clipPlayers: {}, + ramRecPlayers: {}, + stillPlayers: {}, + soundPlayers: {}, + } + + public static fromTimeline( + timelineState: Timeline.StateInTime, + mappings: Mappings + ): KairosDeviceState { + const builder = new KairosStateBuilder() + + // Sort layer based on Layer name + const sortedLayers = Object.entries>(timelineState) + .map(([layerName, tlObject]) => ({ layerName, tlObject })) + .sort((a, b) => a.layerName.localeCompare(b.layerName)) + + // For every layer, augment the state + for (const { tlObject, layerName } of sortedLayers) { + const content = tlObject.content + + const mapping = mappings[layerName] 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.id) + } + break + case MappingKairosType.RamRecPlayer: + if (content.type === TimelineContentTypeKairos.RAMREC_PLAYER) { + builder._applyRamRecPlayer(mapping.options, content, tlObject.id) + } + break + case MappingKairosType.StillPlayer: + if (content.type === TimelineContentTypeKairos.STILL_PLAYER) { + builder._applyStillPlayer(mapping.options, content, tlObject.id) + } + break + case MappingKairosType.SoundPlayer: + if (content.type === TimelineContentTypeKairos.SOUND_PLAYER) { + builder._applySoundPlayer(mapping.options, content, tlObject.id) + } + break + default: + assertNever(mapping.options) + break + } + } + } + + 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, + timelineObjId: string + ): 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.clipPlayer, + timelineObjId + ) + } + + private _applyRamRecPlayer( + mapping: MappingKairosRamRecPlayer, + content: TimelineContentKairosRamRecPlayer, + timelineObjId: string + ): 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.ramRecPlayer, + timelineObjId + ) + } + + private _applyStillPlayer( + mapping: MappingKairosStillPlayer, + content: TimelineContentKairosStillPlayer, + timelineObjId: string + ): void { + if (typeof mapping.playerId !== 'number' || mapping.playerId < 1) return + + const playerId = mapping.playerId + + this.#deviceState.stillPlayers[playerId] = this._mergeState( + this.#deviceState.stillPlayers[playerId], + playerId, + content, + timelineObjId + ) + } + + private _applySoundPlayer( + mapping: MappingKairosSoundPlayer, + content: TimelineContentKairosSoundPlayer, + timelineObjId: string + ): 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.soundPlayer, + timelineObjId + ) + } +} 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 9e47083faf..4e79faca73 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 0ccf587dcf..478f147aa6 100644 --- a/packages/timeline-state-resolver/src/integrations/sofieChef/index.ts +++ b/packages/timeline-state-resolver/src/integrations/sofieChef/index.ts @@ -11,7 +11,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 afbd57057c..d32d10080f 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 @@ -8,6 +8,7 @@ import { Mapping, Timeline, 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 }, + 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 5b1f759d26..1ff5dc7233 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 { @@ -111,7 +112,12 @@ describe('TimelineStateConverter.getTriCasterStateFromTimelineState', () => { content: { deviceType: DeviceType.TRICASTER, type: TimelineContentTypeTriCaster.ME, - me: { programInput: 'input2', previewInput: 'input3', transitionEffect: 5, transitionDuration: 20 }, + me: { + programInput: 'input2', + previewInput: 'input3', + transitionEffect: 5, + transitionDuration: 20, + } as TriCasterMixEffect, }, }), tc_me0_1: wrapIntoResolvedInstance({ @@ -339,7 +345,12 @@ describe('TimelineStateConverter.getTriCasterStateFromTimelineState', () => { content: { deviceType: DeviceType.TRICASTER, type: TimelineContentTypeTriCaster.ME, - me: { programInput: 'input2', previewInput: 'input3', transitionEffect: 5, transitionDuration: 20 }, + me: { + programInput: 'input2', + previewInput: 'input3', + transitionEffect: 5, + transitionDuration: 20, + } as TriCasterMixEffect, }, }), tc_me0_1: 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 384818b639..ae9aa5acba 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 d027b087b1..370fb17ddf 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/devices.ts b/packages/timeline-state-resolver/src/service/devices.ts index e0b3da0010..44d2ede181 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: () => 'salvo', + }, [DeviceType.LAWO]: { deviceClass: LawoDevice, canConnect: true, diff --git a/packages/timeline-state-resolver/tsconfig.build.json b/packages/timeline-state-resolver/tsconfig.build.json index 8e7950482d..4bf5160a99 100755 --- a/packages/timeline-state-resolver/tsconfig.build.json +++ b/packages/timeline-state-resolver/tsconfig.build.json @@ -11,6 +11,9 @@ }, "types": ["node"], + "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 4f7717103e..f414b4f3f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7328,6 +7328,15 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"kairos-connection@npm:0.0.2-nightly-main-20250909-152736-9c6b607.0": + version: 0.0.2-nightly-main-20250909-152736-9c6b607.0 + resolution: "kairos-connection@npm:0.0.2-nightly-main-20250909-152736-9c6b607.0" + dependencies: + tslib: ^2.8.1 + checksum: 167b5aae7d4fa012509b70d86395aa950301cc74d95db77ade144a0df572d3d52d9fbd2eccad92d5c19248443940dc161a4df6b4051745a9c4971ab3d7e017cc + languageName: node + linkType: hard + "keyv@npm:^4.0.0": version: 4.5.2 resolution: "keyv@npm:4.5.2" @@ -11296,7 +11305,7 @@ asn1@evs-broadcast/node-asn1: ts-jest: ^29.2.4 ts-node: ^8.10.2 typedoc: ^0.23.28 - typescript: ~4.9.5 + typescript: ~5.1 languageName: unknown linkType: soft @@ -11322,6 +11331,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-connection: 0.0.2-nightly-main-20250909-152736-9c6b607.0 tslib: ^2.6.3 languageName: unknown linkType: soft @@ -11346,6 +11356,7 @@ asn1@evs-broadcast/node-asn1: hyperdeck-connection: 2.0.1 jest-mock-extended: ^3.0.7 json-schema-to-typescript: ^10.1.5 + kairos-connection: 0.0.2-nightly-main-20250909-152736-9c6b607.0 klona: ^2.0.6 obs-websocket-js: ^5.0.6 osc: ^2.4.5 @@ -11577,10 +11588,10 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.5.0, tslib@npm:^2.6.0, tslib@npm:^2.6.2, tslib@npm:^2.6.3": - version: 2.6.3 - resolution: "tslib@npm:2.6.3" - checksum: 74fce0e100f1ebd95b8995fbbd0e6c91bdd8f4c35c00d4da62e285a3363aaa534de40a80db30ecfd388ed7c313c42d930ee0eaf108e8114214b180eec3dbe6f5 +"tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.5.0, tslib@npm:^2.6.0, tslib@npm:^2.6.2, tslib@npm:^2.6.3, tslib@npm:^2.8.1": + version: 2.8.1 + resolution: "tslib@npm:2.8.1" + checksum: e4aba30e632b8c8902b47587fd13345e2827fa639e7c3121074d5ee0880723282411a8838f830b55100cbe4517672f84a2472667d355b81e8af165a55dc6203a languageName: node linkType: hard @@ -11747,7 +11758,7 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"typescript@npm:^3 || ^4, typescript@npm:^4.2.4, typescript@npm:~4.9.5": +"typescript@npm:^3 || ^4, typescript@npm:^4.2.4": version: 4.9.5 resolution: "typescript@npm:4.9.5" bin: @@ -11757,7 +11768,17 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"typescript@patch:typescript@^3 || ^4#~builtin, typescript@patch:typescript@^4.2.4#~builtin, typescript@patch:typescript@~4.9.5#~builtin": +"typescript@npm:~5.1": + version: 5.1.6 + resolution: "typescript@npm:5.1.6" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: b2f2c35096035fe1f5facd1e38922ccb8558996331405eb00a5111cc948b2e733163cc22fab5db46992aba7dd520fff637f2c1df4996ff0e134e77d3249a7350 + languageName: node + linkType: hard + +"typescript@patch:typescript@^3 || ^4#~builtin, typescript@patch:typescript@^4.2.4#~builtin": version: 4.9.5 resolution: "typescript@patch:typescript@npm%3A4.9.5#~builtin::version=4.9.5&hash=23ec76" bin: @@ -11767,6 +11788,16 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"typescript@patch:typescript@~5.1#~builtin": + version: 5.1.6 + resolution: "typescript@patch:typescript@npm%3A5.1.6#~builtin::version=5.1.6&hash=85af82" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 21e88b0a0c0226f9cb9fd25b9626fb05b4c0f3fddac521844a13e1f30beb8f14e90bd409a9ac43c812c5946d714d6e0dee12d5d02dfc1c562c5aacfa1f49b606 + languageName: node + linkType: hard + "uglify-js@npm:^3.1.4": version: 3.17.4 resolution: "uglify-js@npm:3.17.4" From 31064c4e250556668288075c683915f25c8aaf7c Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 24 Sep 2025 16:44:10 +0100 Subject: [PATCH 02/21] wip: update lib --- .../package.json | 2 +- .../src/integrations/kairos.ts | 2 +- packages/timeline-state-resolver/package.json | 2 +- yarn.lock | 22 ++++++++++++++----- 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/packages/timeline-state-resolver-types/package.json b/packages/timeline-state-resolver-types/package.json index 4f7ccfbee9..9614d6296b 100644 --- a/packages/timeline-state-resolver-types/package.json +++ b/packages/timeline-state-resolver-types/package.json @@ -83,7 +83,7 @@ "production" ], "dependencies": { - "kairos-connection": "0.0.2-nightly-main-20250909-152736-9c6b607.0", + "kairos-lib": "0.0.2-nightly-feat-kairos-lib-20250924-153458-fd923a2.0", "tslib": "^2.6.3" }, "publishConfig": { diff --git a/packages/timeline-state-resolver-types/src/integrations/kairos.ts b/packages/timeline-state-resolver-types/src/integrations/kairos.ts index 069325f051..fb12c30627 100644 --- a/packages/timeline-state-resolver-types/src/integrations/kairos.ts +++ b/packages/timeline-state-resolver-types/src/integrations/kairos.ts @@ -10,7 +10,7 @@ import type { UpdateSceneSnapshotObject, UpdateAuxObject, // eslint-disable-next-line node/no-missing-import -} from 'kairos-connection' // TODO - this needs to be a types/lib package, not the connection package +} from 'kairos-lib' export enum TimelineContentTypeKairos { SCENE = 'scene', diff --git a/packages/timeline-state-resolver/package.json b/packages/timeline-state-resolver/package.json index 5584f966c3..eff5dac0c7 100644 --- a/packages/timeline-state-resolver/package.json +++ b/packages/timeline-state-resolver/package.json @@ -103,7 +103,7 @@ "got": "^11.8.6", "hpagent": "^1.2.0", "hyperdeck-connection": "2.0.1", - "kairos-connection": "0.0.2-nightly-main-20250909-152736-9c6b607.0", + "kairos-connection": "0.0.2-nightly-feat-kairos-lib-20250924-153458-fd923a2.0", "klona": "^2.0.6", "obs-websocket-js": "^5.0.6", "osc": "^2.4.5", diff --git a/yarn.lock b/yarn.lock index f414b4f3f1..4935f77afe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7328,12 +7328,22 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"kairos-connection@npm:0.0.2-nightly-main-20250909-152736-9c6b607.0": - version: 0.0.2-nightly-main-20250909-152736-9c6b607.0 - resolution: "kairos-connection@npm:0.0.2-nightly-main-20250909-152736-9c6b607.0" +"kairos-connection@npm:0.0.2-nightly-feat-kairos-lib-20250924-153458-fd923a2.0": + version: 0.0.2-nightly-feat-kairos-lib-20250924-153458-fd923a2.0 + resolution: "kairos-connection@npm:0.0.2-nightly-feat-kairos-lib-20250924-153458-fd923a2.0" dependencies: + kairos-lib: 0.0.2-nightly-feat-kairos-lib-20250924-153458-fd923a2.0 tslib: ^2.8.1 - checksum: 167b5aae7d4fa012509b70d86395aa950301cc74d95db77ade144a0df572d3d52d9fbd2eccad92d5c19248443940dc161a4df6b4051745a9c4971ab3d7e017cc + checksum: ef83af471e05a751f02f1fc6ef821548a00927ab59361309be91e1181c363eef2d90fdc836248a98acc31e53540d0bf6cdfa4349abfc7d4cc5f71196e028df24 + languageName: node + linkType: hard + +"kairos-lib@npm:0.0.2-nightly-feat-kairos-lib-20250924-153458-fd923a2.0": + version: 0.0.2-nightly-feat-kairos-lib-20250924-153458-fd923a2.0 + resolution: "kairos-lib@npm:0.0.2-nightly-feat-kairos-lib-20250924-153458-fd923a2.0" + dependencies: + tslib: ^2.8.1 + checksum: f42bb9e2a9802592d7297d8ce7ab2ca32783bd25e7f1a3a700d53708fa0870d3c0fca3eadf4853e01364b63f154a2035aa026905b8a19afcd7c6f422dc6979f1 languageName: node linkType: hard @@ -11331,7 +11341,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-connection: 0.0.2-nightly-main-20250909-152736-9c6b607.0 + kairos-lib: 0.0.2-nightly-feat-kairos-lib-20250924-153458-fd923a2.0 tslib: ^2.6.3 languageName: unknown linkType: soft @@ -11356,7 +11366,7 @@ asn1@evs-broadcast/node-asn1: hyperdeck-connection: 2.0.1 jest-mock-extended: ^3.0.7 json-schema-to-typescript: ^10.1.5 - kairos-connection: 0.0.2-nightly-main-20250909-152736-9c6b607.0 + kairos-connection: 0.0.2-nightly-feat-kairos-lib-20250924-153458-fd923a2.0 klona: ^2.0.6 obs-websocket-js: ^5.0.6 osc: ^2.4.5 From 5e90e92e62c018b7abf2a109fde416d9fb7df4f7 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Thu, 25 Sep 2025 14:33:56 +0200 Subject: [PATCH 03/21] feat: add clip player support --- .../package.json | 2 +- .../src/integrations/kairos.ts | 11 +- packages/timeline-state-resolver/package.json | 2 +- .../kairos/$schemas/mappings.json | 104 +++- .../kairos/__tests__/diffState.spec.ts | 487 ++++++++++++++++++ .../src/integrations/kairos/__tests__/lib.ts | 46 ++ .../kairos/__tests__/stateBuilder.spec.ts | 85 +++ .../src/integrations/kairos/commands.ts | 86 +++- .../src/integrations/kairos/diffState.ts | 69 ++- .../src/integrations/kairos/diffState/lib.ts | 39 ++ .../kairos/diffState/media-players.ts | 233 +++++++++ .../src/integrations/kairos/index.ts | 2 +- .../src/integrations/kairos/stateBuilder.ts | 92 +++- .../src/service/devices.ts | 2 +- yarn.lock | 12 +- 15 files changed, 1190 insertions(+), 82 deletions(-) create mode 100644 packages/timeline-state-resolver/src/integrations/kairos/__tests__/diffState.spec.ts create mode 100644 packages/timeline-state-resolver/src/integrations/kairos/__tests__/lib.ts create mode 100644 packages/timeline-state-resolver/src/integrations/kairos/__tests__/stateBuilder.spec.ts create mode 100644 packages/timeline-state-resolver/src/integrations/kairos/diffState/lib.ts create mode 100644 packages/timeline-state-resolver/src/integrations/kairos/diffState/media-players.ts diff --git a/packages/timeline-state-resolver-types/package.json b/packages/timeline-state-resolver-types/package.json index 4f7ccfbee9..0f647a4a7d 100644 --- a/packages/timeline-state-resolver-types/package.json +++ b/packages/timeline-state-resolver-types/package.json @@ -83,7 +83,7 @@ "production" ], "dependencies": { - "kairos-connection": "0.0.2-nightly-main-20250909-152736-9c6b607.0", + "kairos-connection": "0.0.2-nightly-main-20250925-094955-e3062ba.0", "tslib": "^2.6.3" }, "publishConfig": { diff --git a/packages/timeline-state-resolver-types/src/integrations/kairos.ts b/packages/timeline-state-resolver-types/src/integrations/kairos.ts index 069325f051..3761fe9c65 100644 --- a/packages/timeline-state-resolver-types/src/integrations/kairos.ts +++ b/packages/timeline-state-resolver-types/src/integrations/kairos.ts @@ -132,7 +132,10 @@ export interface TimelineContentKairosPlayerState extends Partial> { // clip player / ramrec player - /** Reference to the file to be played */ + /** + * Reference to the file to be played + * @example "MEDIA.clips.amb.mxf" + */ clip?: TClip /** @@ -141,7 +144,11 @@ export interface TimelineContentKairosPlayerState */ repeat?: boolean - /** The point where the file starts playing [milliseconds from start of file] */ + /** + * 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. */ diff --git a/packages/timeline-state-resolver/package.json b/packages/timeline-state-resolver/package.json index 5584f966c3..0add75c1b0 100644 --- a/packages/timeline-state-resolver/package.json +++ b/packages/timeline-state-resolver/package.json @@ -103,7 +103,7 @@ "got": "^11.8.6", "hpagent": "^1.2.0", "hyperdeck-connection": "2.0.1", - "kairos-connection": "0.0.2-nightly-main-20250909-152736-9c6b607.0", + "kairos-connection": "0.0.2-nightly-main-20250925-094955-e3062ba.0", "klona": "^2.0.6", "obs-websocket-js": "^5.0.6", "osc": "^2.4.5", diff --git a/packages/timeline-state-resolver/src/integrations/kairos/$schemas/mappings.json b/packages/timeline-state-resolver/src/integrations/kairos/$schemas/mappings.json index faec1f1b2f..09547590f2 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/$schemas/mappings.json +++ b/packages/timeline-state-resolver/src/integrations/kairos/$schemas/mappings.json @@ -7,15 +7,21 @@ "properties": { "sceneName": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "ui:title": "Scene", "ui:description": "The Scene to use", "ui:summaryTitle": "Scene", "ui:displayType": "breadcrumbs", - "default": ["Main"] + "default": [ + "Main" + ] } }, - "required": ["sceneName"], + "required": [ + "sceneName" + ], "additionalProperties": false }, "scene-layer": { @@ -23,24 +29,35 @@ "properties": { "sceneName": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "ui:title": "Scene", "ui:description": "The Scene to use", "ui:summaryTitle": "Scene", "ui:displayType": "breadcrumbs", - "default": ["Main"] + "default": [ + "Main" + ] }, "layerName": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "ui:title": "Layer", "ui:description": "The layer in a scene to use", "ui:summaryTitle": "Layer", "ui:displayType": "breadcrumbs", - "default": ["Background"] + "default": [ + "Background" + ] } }, - "required": ["sceneName", "layerName"], + "required": [ + "sceneName", + "layerName" + ], "additionalProperties": false }, "aux": { @@ -55,7 +72,9 @@ "default": "IP-AUX1" } }, - "required": ["auxName"], + "required": [ + "auxName" + ], "additionalProperties": false }, "macro": { @@ -75,9 +94,28 @@ "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"], + "required": [ + "playerId" + ], "additionalProperties": false }, "ram-rec-player": { @@ -91,9 +129,28 @@ "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"], + "required": [ + "playerId" + ], "additionalProperties": false }, "still-player": { @@ -109,7 +166,9 @@ "min": 1 } }, - "required": ["playerId"], + "required": [ + "playerId" + ], "additionalProperties": false }, "sound-player": { @@ -123,9 +182,28 @@ "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"], + "required": [ + "playerId" + ], "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..d696eecb36 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/__tests__/diffState.spec.ts @@ -0,0 +1,487 @@ +import { + DeviceType, + MappingKairosType, + Mappings, + SomeMappingKairos, + TimelineContentTypeKairos, +} from 'timeline-state-resolver-types' +import { KairosCommandWithContext } from '..' +import { diffKairosStates } from '../diffState' +import { KairosDeviceState, KairosStateBuilder } from '../stateBuilder' +import { tlObjectInstance } from './lib' + +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( + { + layers: { + mainSceneBackgroundLayer: tlObjectInstance(now, { + deviceType: DeviceType.KAIROS, + type: TimelineContentTypeKairos.SCENE_LAYER, + sceneLayer: { + sourcePgm: { + realm: 'input', + path: 'camera1', + }, + }, + }), + }, + nextEvents: [], + time: now, + }, + DEFAULT_MAPPINGS + ), + [ + { + context: expect.any(String), + timelineObjId: expect.any(String), + command: { + type: 'scene-layer', + ref: { + realm: 'scene-layer', + scenePath: ['Main'], + layerPath: ['Background'], + }, + values: { + sourcePgm: { + realm: 'input', + path: 'camera1', + }, + }, + }, + }, + ] + ) + }) + 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( + { + layers: { + clipPlayer1: tlObjectInstance(now, { + deviceType: DeviceType.KAIROS, + type: TimelineContentTypeKairos.CLIP_PLAYER, + clipPlayer: { + clip: { realm: 'media-clip', clipPath: ['amb.mp4'] }, + playing: true, + }, + }), + }, + nextEvents: [], + 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: 40, + }, + // Play command: + { + context: expect.any(String), + timelineObjId: expect.any(String), + command: { + type: 'clip-player:do', + 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( + { + layers: { + clipPlayer1: tlObjectInstance(now, { + deviceType: DeviceType.KAIROS, + type: TimelineContentTypeKairos.CLIP_PLAYER, + clipPlayer: { + clip: { realm: 'media-clip', clipPath: ['amb.mp4'] }, + playing: false, // Is paused + }, + }), + }, + nextEvents: [], + 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: 'clip-player:do', + 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( + { + layers: { + clipPlayer1: tlObjectInstance(now, { + deviceType: DeviceType.KAIROS, + type: TimelineContentTypeKairos.CLIP_PLAYER, + clipPlayer: { + clip: { realm: 'media-clip', clipPath: ['amb.mp4'] }, + playing: true, // Is playing + }, + }), + }, + nextEvents: [], + 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: 40, + }, + { + context: expect.any(String), + timelineObjId: expect.any(String), + command: { + type: 'clip-player:do', + 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( + { + layers: {}, + time: now, + nextEvents: [], + }, + DEFAULT_MAPPINGS + ) // Empty state + compareStates(DEFAULT_MAPPINGS, oldState, newState, [ + { + context: expect.any(String), + timelineObjId: expect.any(String), + command: { + type: 'clip-player:do', + 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( + { + layers: { + clipPlayer1: tlObjectInstance( + // The clip was supposed to start 0.5s ago: + now - 500, + { + 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) + }, + } + ), + }, + nextEvents: [], + 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: 40, + }, + // Play command: + { + context: expect.any(String), + timelineObjId: expect.any(String), + command: { + type: 'clip-player:do', + 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( + { + layers: { + clipPlayer1: tlObjectInstance( + // We're a bit late, should not affect the outcome though: + now - 500, + { + 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 + }, + } + ), + }, + nextEvents: [], + 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: 'clip-player:do', + 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( + { + layers: { + clipPlayer1: tlObjectInstance(now, { + deviceType: DeviceType.KAIROS, + type: TimelineContentTypeKairos.CLIP_PLAYER, + clipPlayer: { + clip: { + realm: 'media-clip', + clipPath: ['amb.mp4'], + }, + playing: true, + seek: 100, + }, + }), + }, + nextEvents: [], + 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: 40, + }, + { + context: expect.any(String), + timelineObjId: expect.any(String), + command: { + type: 'clip-player:do', + 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: {}, + stillPlayers: {}, +} 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..b6ab7f716a --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/__tests__/lib.ts @@ -0,0 +1,46 @@ +import { ResolvedTimelineObjectInstance } from 'superfly-timeline' +import { TimelineContentKairosAny, TSRTimelineContent } from 'timeline-state-resolver-types' +import { KairosDeviceState } from '../stateBuilder' + +/** Convenience function to convert some KAIROS content into a ResolvedTimelineObjectInstance */ +export function tlObjectInstance( + startTime: number, + content: TimelineContentKairosAny +): ResolvedTimelineObjectInstance { + return { + content, + enable: { start: startTime }, + id: 'obj0', + instance: { + id: '@obj0_instance0', + start: startTime, + end: null, + references: [], + }, + layer: 'N/A', + resolved: { + directReferences: [], + firstResolved: true, + instances: [], + isKeyframe: false, + isSelfReferencing: false, + levelDeep: 0, + parentId: undefined, + resolvedConflicts: false, + resolvedReferences: false, + resolving: false, + }, + } +} + +export const EMPTY_STATE: Omit = { + aux: {}, + clipPlayers: {}, + macros: {}, + ramRecPlayers: {}, + sceneLayers: {}, + sceneSnapshots: {}, + scenes: {}, + soundPlayers: {}, + stillPlayers: {}, +} 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..4318cd9118 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/__tests__/stateBuilder.spec.ts @@ -0,0 +1,85 @@ +import { + DeviceType, + MappingKairosType, + Mappings, + SomeMappingKairos, + TimelineContentTypeKairos, +} from 'timeline-state-resolver-types' +import { KairosStateBuilder } from '../stateBuilder' +import { EMPTY_STATE, tlObjectInstance } from './lib' + +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( + { + layers: {}, + nextEvents: [], + time: 123, + }, + DEFAULT_MAPPINGS + ) + ).toStrictEqual({ ...EMPTY_STATE, stateTime: 123 }) + }) + test('Set ', () => { + expect( + KairosStateBuilder.fromTimeline( + { + layers: { + mainSceneBackgroundLayer: tlObjectInstance(10000, { + deviceType: DeviceType.KAIROS, + type: TimelineContentTypeKairos.SCENE_LAYER, + sceneLayer: { + sourcePgm: { + realm: 'input', + path: 'camera1', + }, + }, + }), + }, + nextEvents: [], + time: 0, + }, + DEFAULT_MAPPINGS + ) + ).toStrictEqual({ + ...EMPTY_STATE, + stateTime: 0, + sceneLayers: { + 'SCENES.Main.Layers.Background': { + ref: { + layerPath: ['Background'], + realm: 'scene-layer', + scenePath: ['Main'], + }, + state: { + sourcePgm: { + path: 'camera1', + realm: 'input', + }, + }, + timelineObjIds: ['obj0'], + }, + }, + }) + }) +}) diff --git a/packages/timeline-state-resolver/src/integrations/kairos/commands.ts b/packages/timeline-state-resolver/src/integrations/kairos/commands.ts index 8470c04b16..af1e19574a 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/commands.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/commands.ts @@ -9,6 +9,8 @@ import type { MacroRef, SceneSnapshotRef, KairosConnection, + UpdateRamRecPlayerObject, + UpdateAudioPlayerObject, // eslint-disable-next-line node/no-missing-import } from 'kairos-connection' import { assertNever } from '../../lib' @@ -20,6 +22,7 @@ export type KairosCommandAny = | KairosAuxCommand | KairosMacroCommand | KairosClipPlayerCommand + | KairosClipPlayerCommandMethod | KairosRamRecPlayerCommand | KairosStillPlayerCommand | KairosSoundPlayerCommand @@ -72,13 +75,32 @@ export interface KairosClipPlayerCommand { values: Partial } +export interface KairosClipPlayerCommandMethod { + type: 'clip-player:do' + playerId: number + 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 + values: Partial } export interface KairosStillPlayerCommand { @@ -94,7 +116,7 @@ export interface KairosSoundPlayerCommand { playerId: number - values: Partial + values: Partial } export async function sendCommand(kairos: KairosConnection, command: KairosCommandAny): Promise { @@ -123,9 +145,67 @@ export async function sendCommand(kairos: KairosConnection, command: KairosComma await kairos.macroStop(command.macroRef) } break - case 'clip-player': + case 'clip-player': { + if (command.values.clip) { + await kairos.loadClipPlayerClip(command.playerId, command.values.clip, command.values.position) + delete command.values.clip + delete command.values.position + } await kairos.updateClipPlayer(command.playerId, command.values) break + } + case 'clip-player:do': { + switch (command.command) { + case 'begin': + await kairos.clipPlayerBegin(command.playerId) + break + case 'rewind': + await kairos.clipPlayerRewind(command.playerId) + break + case 'stepBack': + await kairos.clipPlayerStepBack(command.playerId) + break + case 'reverse': + await kairos.clipPlayerReverse(command.playerId) + break + case 'play': + await kairos.clipPlayerPlay(command.playerId) + break + case 'pause': + await kairos.clipPlayerPause(command.playerId) + break + case 'stop': + await kairos.clipPlayerStop(command.playerId) + break + case 'stepForward': + await kairos.clipPlayerStepForward(command.playerId) + break + case 'fastForward': + await kairos.clipPlayerFastForward(command.playerId) + break + case 'end': + await kairos.clipPlayerEnd(command.playerId) + break + case 'playlistBegin': + await kairos.clipPlayerPlaylistBegin(command.playerId) + break + case 'playlistBack': + await kairos.clipPlayerPlaylistBack(command.playerId) + break + case 'playlistNext': + await kairos.clipPlayerPlaylistNext(command.playerId) + break + case 'playlistEnd': + await kairos.clipPlayerPlaylistEnd(command.playerId) + break + default: + assertNever(command.command) + throw new Error(`Unknown Kairos command.command type: ${command.command}`) + } + + break + } + case 'ram-rec-player': await kairos.updateRamRecorder(command.playerId, command.values) break diff --git a/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts b/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts index 803d439381..a371b11757 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts @@ -4,7 +4,9 @@ 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 { isEqual } from 'underscore' + +import { diffMediaPlayers } from './diffState/media-players' +import { diffObject, getAllKeysString } from './diffState/lib' export function diffKairosStates( oldKairosState: KairosDeviceState | undefined, @@ -12,7 +14,16 @@ export function diffKairosStates( mappings: Mappings ): KairosCommandWithContext[] { // Make sure there is something to diff against - oldKairosState = oldKairosState ?? KairosStateBuilder.fromTimeline({}, mappings) + oldKairosState = + oldKairosState ?? + KairosStateBuilder.fromTimeline( + { + time: 0, + layers: {}, + nextEvents: [], + }, + mappings + ) const commands: KairosCommandWithContext[] = [] @@ -31,10 +42,26 @@ export function diffKairosStates( commands.push(...diffMacros(oldKairosState.macros, newKairosState.macros)) - // commands.push(...diffClipPlayers(oldKairosState.clipPlayers, newKairosState.clipPlayers)) - // commands.push(...diffRamRecPlayers(oldKairosState.ramRecPlayers, newKairosState.ramRecPlayers)) + 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(...diffStillPlayers(oldKairosState.stillPlayers, newKairosState.stillPlayers)) - // commands.push(...diffSoundPlayers(oldKairosState.soundPlayers, newKairosState.soundPlayers)) return commands } @@ -207,35 +234,3 @@ function diffMacros( return commands } - -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 -} - -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) -} -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/lib.ts b/packages/timeline-state-resolver/src/integrations/kairos/diffState/lib.ts new file mode 100644 index 0000000000..a901c76859 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/diffState/lib.ts @@ -0,0 +1,39 @@ +import { isEqual } from 'underscore' + +/** + * Does a shallow comparision of two objects, returning an object with only the changed keys. + * @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 +} + +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..6c39f89b10 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/diffState/media-players.ts @@ -0,0 +1,233 @@ +import { + MediaClipRef, + MediaRamRecRef, + MediaSoundRef, + UpdateClipPlayerObject, + UpdateRamRecPlayerObject, + UpdateAudioPlayerObject, +} from 'kairos-connection' +import { TimelineObjectInstance } from 'superfly-timeline' +import { TimelineContentKairosPlayerState } from 'timeline-state-resolver-types' +import { KairosCommandWithContext } from '..' +import { + KairosClipPlayerCommand, + KairosRamRecPlayerCommand, + KairosSoundPlayerCommand, + KairosClipPlayerCommandMethod, +} from '../commands' +import { MappingOptions } from '../stateBuilder' +import { diffObject, getAllKeysString } from './lib' + +export function diffMediaPlayers( + stateTime: number, + playerType: KairosClipPlayerCommand['type'] | KairosRamRecPlayerCommand['type'] | KairosSoundPlayerCommand['type'], + oldClipPlayers: Record, + newClipPlayers: Record +): KairosCommandWithContext[] { + const commands: KairosCommandWithContext[] = [] + + const keys = getAllKeysString(oldClipPlayers, newClipPlayers) + for (const key of keys) { + const keyNum = parseInt(key) + + const newClipPlayer = newClipPlayers[keyNum] + const oldClipPlayer = oldClipPlayers[keyNum] + + const cpRef = newClipPlayer?.ref || oldClipPlayer?.ref + if (!cpRef) continue // No ClipPlayer to diff + + if (!newClipPlayer && oldClipPlayer) { + // ClipPlayer obj was removed, stop it: + + const clearPlayerOnStop = oldClipPlayer.state.mappingOptions.clearPlayerOnStop || false + + if (clearPlayerOnStop) { + commands.push({ + timelineObjId: oldClipPlayer?.timelineObjIds.join(' & ') ?? '', + context: `key=${key} newClipPlayer=${!!newClipPlayer} oldClipPlayer=${!!oldClipPlayer}`, + command: { + type: 'clip-player', + playerId: keyNum, + values: { + clip: null, // Clear the clip + }, + }, + }) + } else { + commands.push({ + timelineObjId: oldClipPlayer?.timelineObjIds.join(' & ') ?? '', + context: `key=${key} newClipPlayer=${!!newClipPlayer} oldClipPlayer=${!!oldClipPlayer}`, + command: { + type: 'clip-player:do', + playerId: keyNum, + command: 'stop', + }, + }) + } + } else if (newClipPlayer) { + const framerate = newClipPlayer.state.mappingOptions.framerate || 25 + + const oldState = getClipPlayerState(oldClipPlayer) + const newState = getClipPlayerState(newClipPlayer) + + const diff = diffObject(oldState, newState) + + if (diff) { + const cmd: Partial = {} + let doCommand: KairosClipPlayerCommandMethod['command'] | null = null + + if ( + diff.seekAbsolute !== undefined && + newState.seekAbsolute !== undefined && + oldState?.seekAbsolute !== undefined && + Math.abs(newState.seekAbsolute - oldState.seekAbsolute) < 100 + ) { + // If the absolute seek position diff is within 100ms, ignore it: + delete diff.seekAbsolute + } + + if (playerType === 'clip-player' || playerType === 'ram-rec-player') { + // color and colorOverwrite only apply to clip-players and ram-rec-players + + const cmd0 = cmd as Partial + if (diff.color !== undefined) { + cmd0.color = diff.color + } + if (diff.colorOverwrite !== undefined) { + cmd0.colorOverwrite = diff.colorOverwrite + } + } + if (diff.repeat !== undefined) { + cmd.repeat = diff.repeat + } + + if ( + // The clip has changed, trigger a play/stop command: + diff.clip !== undefined || + // The seek position has changed, move the playhead: + diff.seekAbsolute !== undefined + ) { + // This will trigger a play command below: + newState.playing = newState.playing ?? false + diff.playing = newState.playing + } + + if (diff.playing === true) { + // Start playing the clip! + + // When starting playing, we sync the clip, position and repeat state: + if (newState.clip !== undefined) { + cmd.clip = newState.clip + } + if (newState.seekAbsolute !== undefined) { + const newSeek = Math.max(0, -relativeTime(stateTime, newState.seekAbsolute)) + + cmd.position = Math.floor((newSeek / 1000) * framerate) + } + cmd.repeat = newState.repeat ?? false + + doCommand = 'play' + } else if (newState.playing === false) { + // 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: + cmd.position = Math.floor((newState.seek / 1000) * framerate) + } + if (newState.clip) { + cmd.clip = newState.clip + } + // Stop / Pause the clip: + if (diff.playing === false) doCommand = 'pause' + } + + if (!isEmptyObject(cmd)) { + commands.push({ + timelineObjId: newClipPlayer?.timelineObjIds.join(' & ') ?? '', + context: `key=${key} diff=${JSON.stringify(diff)}`, + command: { + type: playerType, + playerId: keyNum, + values: cmd as any, + }, + preliminary: doCommand === 'play' ? 40 : 0, // Send this command a bit early if there's a Play command + }) + } + + if (doCommand !== null) { + commands.push({ + timelineObjId: newClipPlayer?.timelineObjIds.join(' & ') ?? '', + context: `key=${key} diff=${JSON.stringify(diff)}`, + command: { + type: 'clip-player:do', + playerId: keyNum, + command: doCommand, + }, + }) + } + } + } + } + return commands +} + +type MediaPlayerAny = TimelineContentKairosPlayerState +type MediaPlayerOuterState = { + ref: number + state: { content: MediaPlayerAny; instance: TimelineObjectInstance; mappingOptions: MappingOptions } + timelineObjIds: string[] +} +type MediaPlayerInnerState = MediaPlayerAny & { + seekAbsolute: 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 { + seekAbsolute: undefined, + } + + const seek = mediaPlayer.state.content.seek + const clipPlayerState: MediaPlayerInnerState = { + ...mediaPlayer.state.content, + seekAbsolute: 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.seekAbsolute + } + + if (clipPlayerState.playing === false) { + // If not playing, the absolute seek position is not relevant + delete clipPlayerState.seekAbsolute + } + + return clipPlayerState +} diff --git a/packages/timeline-state-resolver/src/integrations/kairos/index.ts b/packages/timeline-state-resolver/src/integrations/kairos/index.ts index 0c5229761b..cfcf356f5c 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/index.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/index.ts @@ -114,7 +114,7 @@ export class KairosDevice implements Device, mappings: Mappings ): KairosDeviceState { - const deviceState = KairosStateBuilder.fromTimeline(timelineState.layers, mappings) + const deviceState = KairosStateBuilder.fromTimeline(timelineState, mappings) return deviceState } diff --git a/packages/timeline-state-resolver/src/integrations/kairos/stateBuilder.ts b/packages/timeline-state-resolver/src/integrations/kairos/stateBuilder.ts index 6ab6015d7e..b58ce685c0 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/stateBuilder.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/stateBuilder.ts @@ -48,8 +48,10 @@ import { refMacro, // eslint-disable-next-line node/no-missing-import } from 'kairos-connection' +import { TimelineObjectInstance } from 'superfly-timeline' export interface KairosDeviceState { + stateTime: number scenes: Record; timelineObjIds: string[] } | undefined> sceneSnapshots: Record< string, @@ -71,11 +73,29 @@ export interface KairosDeviceState { > clipPlayers: Record< number, - { ref: number; state: TimelineContentKairosPlayerState; timelineObjIds: string[] } | undefined + | { + ref: number + state: { + content: TimelineContentKairosPlayerState + instance: TimelineObjectInstance + mappingOptions: MappingOptions + } + timelineObjIds: string[] + } + | undefined > ramRecPlayers: Record< number, - { ref: number; state: TimelineContentKairosPlayerState; timelineObjIds: string[] } | undefined + | { + ref: number + state: { + content: TimelineContentKairosPlayerState + instance: TimelineObjectInstance + mappingOptions: MappingOptions + } + timelineObjIds: string[] + } + | undefined > stillPlayers: Record< number, @@ -83,13 +103,23 @@ export interface KairosDeviceState { > soundPlayers: Record< number, - { ref: number; state: TimelineContentKairosPlayerState; timelineObjIds: string[] } | undefined + | { + 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: {}, @@ -102,13 +132,15 @@ export class KairosStateBuilder { } public static fromTimeline( - timelineState: Timeline.StateInTime, + timelineState: Timeline.TimelineState, mappings: Mappings ): KairosDeviceState { const builder = new KairosStateBuilder() // Sort layer based on Layer name - const sortedLayers = Object.entries>(timelineState) + const sortedLayers = Object.entries>( + timelineState.layers + ) .map(([layerName, tlObject]) => ({ layerName, tlObject })) .sort((a, b) => a.layerName.localeCompare(b.layerName)) @@ -142,12 +174,12 @@ export class KairosStateBuilder { break case MappingKairosType.ClipPlayer: if (content.type === TimelineContentTypeKairos.CLIP_PLAYER) { - builder._applyClipPlayer(mapping.options, content, tlObject.id) + builder._applyClipPlayer(mapping.options, content, tlObject) } break case MappingKairosType.RamRecPlayer: if (content.type === TimelineContentTypeKairos.RAMREC_PLAYER) { - builder._applyRamRecPlayer(mapping.options, content, tlObject.id) + builder._applyRamRecPlayer(mapping.options, content, tlObject) } break case MappingKairosType.StillPlayer: @@ -157,7 +189,7 @@ export class KairosStateBuilder { break case MappingKairosType.SoundPlayer: if (content.type === TimelineContentTypeKairos.SOUND_PLAYER) { - builder._applySoundPlayer(mapping.options, content, tlObject.id) + builder._applySoundPlayer(mapping.options, content, tlObject) } break default: @@ -166,6 +198,7 @@ export class KairosStateBuilder { } } } + builder.#deviceState.stateTime = timelineState.time return builder.#deviceState } @@ -276,7 +309,7 @@ export class KairosStateBuilder { private _applyClipPlayer( mapping: MappingKairosClipPlayer, content: TimelineContentKairosClipPlayer, - timelineObjId: string + timelineObj: Timeline.ResolvedTimelineObjectInstance ): void { if (typeof mapping.playerId !== 'number' || mapping.playerId < 1) return @@ -285,15 +318,22 @@ export class KairosStateBuilder { this.#deviceState.clipPlayers[playerId] = this._mergeState( this.#deviceState.clipPlayers[playerId], playerId, - content.clipPlayer, - timelineObjId + { + content: content.clipPlayer, + instance: timelineObj.instance, + mappingOptions: { + framerate: mapping.framerate, + clearPlayerOnStop: mapping.clearPlayerOnStop, + }, + }, + timelineObj.id ) } private _applyRamRecPlayer( mapping: MappingKairosRamRecPlayer, content: TimelineContentKairosRamRecPlayer, - timelineObjId: string + timelineObj: Timeline.ResolvedTimelineObjectInstance ): void { if (typeof mapping.playerId !== 'number' || mapping.playerId < 1) return @@ -302,8 +342,15 @@ export class KairosStateBuilder { this.#deviceState.ramRecPlayers[playerId] = this._mergeState( this.#deviceState.ramRecPlayers[playerId], playerId, - content.ramRecPlayer, - timelineObjId + { + content: content.ramRecPlayer, + instance: timelineObj.instance, + mappingOptions: { + framerate: mapping.framerate, + clearPlayerOnStop: mapping.clearPlayerOnStop, + }, + }, + timelineObj.id ) } @@ -327,7 +374,7 @@ export class KairosStateBuilder { private _applySoundPlayer( mapping: MappingKairosSoundPlayer, content: TimelineContentKairosSoundPlayer, - timelineObjId: string + timelineObj: Timeline.ResolvedTimelineObjectInstance ): void { if (typeof mapping.playerId !== 'number' || mapping.playerId < 1) return @@ -336,8 +383,19 @@ export class KairosStateBuilder { this.#deviceState.soundPlayers[playerId] = this._mergeState( this.#deviceState.soundPlayers[playerId], playerId, - content.soundPlayer, - timelineObjId + { + content: content.soundPlayer, + instance: timelineObj.instance, + mappingOptions: { + framerate: mapping.framerate, + clearPlayerOnStop: mapping.clearPlayerOnStop, + }, + }, + timelineObj.id ) } } +export type MappingOptions = { + framerate?: number + clearPlayerOnStop?: boolean +} diff --git a/packages/timeline-state-resolver/src/service/devices.ts b/packages/timeline-state-resolver/src/service/devices.ts index 44d2ede181..825cb93110 100644 --- a/packages/timeline-state-resolver/src/service/devices.ts +++ b/packages/timeline-state-resolver/src/service/devices.ts @@ -81,7 +81,7 @@ export const DevicesDict: Record = { deviceClass: KairosDevice, canConnect: true, deviceName: (deviceId: string) => 'Kairos ' + deviceId, - executionMode: () => 'salvo', + executionMode: () => 'sequential', }, [DeviceType.LAWO]: { deviceClass: LawoDevice, diff --git a/yarn.lock b/yarn.lock index f414b4f3f1..4eeba073df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7328,12 +7328,12 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"kairos-connection@npm:0.0.2-nightly-main-20250909-152736-9c6b607.0": - version: 0.0.2-nightly-main-20250909-152736-9c6b607.0 - resolution: "kairos-connection@npm:0.0.2-nightly-main-20250909-152736-9c6b607.0" +"kairos-connection@npm:0.0.2-nightly-main-20250925-094955-e3062ba.0": + version: 0.0.2-nightly-main-20250925-094955-e3062ba.0 + resolution: "kairos-connection@npm:0.0.2-nightly-main-20250925-094955-e3062ba.0" dependencies: tslib: ^2.8.1 - checksum: 167b5aae7d4fa012509b70d86395aa950301cc74d95db77ade144a0df572d3d52d9fbd2eccad92d5c19248443940dc161a4df6b4051745a9c4971ab3d7e017cc + checksum: 1576b3b4c2497d76c74dd8610dcc1ae10494a5ef5db43c35ee4b26f87517e1732c5f5b342ad217d0a0a02158d9a169f2510be2626e8f4e7693cc0873bc7e8b45 languageName: node linkType: hard @@ -11331,7 +11331,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-connection: 0.0.2-nightly-main-20250909-152736-9c6b607.0 + kairos-connection: 0.0.2-nightly-main-20250925-094955-e3062ba.0 tslib: ^2.6.3 languageName: unknown linkType: soft @@ -11356,7 +11356,7 @@ asn1@evs-broadcast/node-asn1: hyperdeck-connection: 2.0.1 jest-mock-extended: ^3.0.7 json-schema-to-typescript: ^10.1.5 - kairos-connection: 0.0.2-nightly-main-20250909-152736-9c6b607.0 + kairos-connection: 0.0.2-nightly-main-20250925-094955-e3062ba.0 klona: ^2.0.6 obs-websocket-js: ^5.0.6 osc: ^2.4.5 From ef6f71258e7c61e4bb86b3d84aadec375520b6fb Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Thu, 25 Sep 2025 14:34:23 +0200 Subject: [PATCH 04/21] feat: add some tsr-actions --- .../src/generated/kairos.ts | 28 +++++------ .../integrations/kairos/$schemas/actions.json | 50 ++++++------------- .../src/integrations/kairos/index.ts | 40 +++++++++++++-- 3 files changed, 63 insertions(+), 55 deletions(-) diff --git a/packages/timeline-state-resolver-types/src/generated/kairos.ts b/packages/timeline-state-resolver-types/src/generated/kairos.ts index c75e08910d..9fd35fb697 100644 --- a/packages/timeline-state-resolver-types/src/generated/kairos.ts +++ b/packages/timeline-state-resolver-types/src/generated/kairos.ts @@ -39,11 +39,15 @@ export interface MappingKairosMacro { export interface MappingKairosClipPlayer { playerId: number + framerate?: number + clearPlayerOnStop?: boolean mappingType: MappingKairosType.ClipPlayer } export interface MappingKairosRamRecPlayer { playerId: number + framerate?: number + clearPlayerOnStop?: boolean mappingType: MappingKairosType.RamRecPlayer } @@ -54,6 +58,8 @@ export interface MappingKairosStillPlayer { export interface MappingKairosSoundPlayer { playerId: number + framerate?: number + clearPlayerOnStop?: boolean mappingType: MappingKairosType.SoundPlayer } @@ -70,16 +76,10 @@ export enum MappingKairosType { export type SomeMappingKairos = MappingKairosScene | MappingKairosSceneLayer | MappingKairosAux | MappingKairosMacro | MappingKairosClipPlayer | MappingKairosRamRecPlayer | MappingKairosStillPlayer | MappingKairosSoundPlayer -export interface ListClipsPayload { - subDirectory?: string[] -} - export type ListClipsResult = { - name: string[] - size: number - datetime?: number - frames: number - framerate: number + name: string + status: number + loadProgress: number }[] export interface ListStillsPayload { @@ -87,13 +87,13 @@ export interface ListStillsPayload { } export type ListStillsResult = { - name: string[] - size: number - datetime?: number + name: string + status: number + loadProgress: number }[] export interface PlayMacroPayload { - ref?: string[] + macroPath: string[] } export enum KairosActions { @@ -102,7 +102,7 @@ export enum KairosActions { PlayMacro = 'playMacro' } export interface KairosActionMethods { - [KairosActions.ListClips]: (payload: ListClipsPayload) => Promise>, + [KairosActions.ListClips]: (payload: Record) => Promise>, [KairosActions.ListStills]: (payload: ListStillsPayload) => Promise>, [KairosActions.PlayMacro]: (payload: PlayMacroPayload) => Promise> } diff --git a/packages/timeline-state-resolver/src/integrations/kairos/$schemas/actions.json b/packages/timeline-state-resolver/src/integrations/kairos/$schemas/actions.json index ed9e3e75d2..d7a9cba32b 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/$schemas/actions.json +++ b/packages/timeline-state-resolver/src/integrations/kairos/$schemas/actions.json @@ -5,48 +5,25 @@ "id": "listClips", "name": "List clips in media folder", "destructive": false, - "payload": { - "type": "object", - "properties": { - "subDirectory": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false - }, "result": { "type": "array", "items": { "type": "object", "properties": { "name": { - "type": "array", - "items": { - "type": "string" - } - }, - "size": { - "type": "number" - }, - "datetime": { - "type": "number" + "type": "string" }, - "frames": { + "status": { "type": "number" }, - "framerate": { + "loadProgress": { "type": "number" } }, "required": [ "name", - "size", - "dateTime", - "frames", - "framerate" + "status", + "loadProgress" ], "additionalProperties": false } @@ -74,21 +51,19 @@ "type": "object", "properties": { "name": { - "type": "array", - "items": { - "type": "string" - } + "type": "string" }, - "size": { + "status": { "type": "number" }, - "datetime": { + "loadProgress": { "type": "number" } }, "required": [ "name", - "size", + "status", + "loadProgress" ], "additionalProperties": false } @@ -101,13 +76,16 @@ "payload": { "type": "object", "properties": { - "ref": { + "macroPath": { "type": "array", "items": { "type": "string" } } }, + "required": [ + "macroPath" + ], "additionalProperties": false } } diff --git a/packages/timeline-state-resolver/src/integrations/kairos/index.ts b/packages/timeline-state-resolver/src/integrations/kairos/index.ts index cfcf356f5c..23e5e2ef48 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/index.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/index.ts @@ -9,9 +9,11 @@ import { KairosDeviceTypes, KairosActionMethods, KairosActions, + ListClipsResult, + ActionExecutionResultCode, } from 'timeline-state-resolver-types' // eslint-disable-next-line node/no-missing-import -import { KairosConnection } from 'kairos-connection' +import { KairosConnection, refMacro } from 'kairos-connection' import type { Device, DeviceContextAPI, CommandWithContext } from 'timeline-state-resolver-api' import { KairosDeviceState, KairosStateBuilder } from './stateBuilder' import { diffKairosStates } from './diffState' @@ -25,13 +27,41 @@ export type KairosCommandWithContext = CommandWithContext { readonly actions: KairosActionMethods = { [KairosActions.ListClips]: async () => { - throw new Error('Not implemented') + const clipNames = await this._kairos.listMediaClips() + + const resultData: ListClipsResult = [] + for (const clipName of clipNames) { + const clipInfo = await this._kairos.getMediaClip(clipName) + if (!clipInfo) continue + resultData.push(clipInfo) + } + return { + result: ActionExecutionResultCode.Ok, + resultData, + } }, [KairosActions.ListStills]: async () => { - throw new Error('Not implemented') + const stillNames = await this._kairos.listMediaStills() + + const resultData: ListClipsResult = [] + for (const stillName of stillNames) { + const stillInfo = await this._kairos.getMediaStill(stillName) + if (!stillInfo) continue + resultData.push(stillInfo) + } + return { + result: ActionExecutionResultCode.Ok, + resultData, + } }, - [KairosActions.PlayMacro]: async () => { - throw new Error('Not implemented') + [KairosActions.PlayMacro]: async (params) => { + if (!params.macroPath || !Array.isArray(params.macroPath)) { + return { result: ActionExecutionResultCode.Error, message: 'Invalid payload: macroPath is not an Array' } + } + await this._kairos.macroPlay(refMacro(params.macroPath)) + return { + result: ActionExecutionResultCode.Ok, + } }, } From ff22ff6a2a01a665c74647053cd9c32d9e1bc2a5 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Wed, 8 Oct 2025 09:29:23 +0200 Subject: [PATCH 05/21] feat: add list-actions to kairos device --- .../src/generated/kairos.ts | 275 +++++++- .../integrations/kairos/$schemas/actions.json | 610 +++++++++++++++-- .../kairos/$schemas/lib/refs.json | 630 ++++++++++++++++++ .../src/integrations/kairos/actions.ts | 178 +++++ .../src/integrations/kairos/index.ts | 20 +- 5 files changed, 1617 insertions(+), 96 deletions(-) create mode 100644 packages/timeline-state-resolver/src/integrations/kairos/$schemas/lib/refs.json create mode 100644 packages/timeline-state-resolver/src/integrations/kairos/actions.ts diff --git a/packages/timeline-state-resolver-types/src/generated/kairos.ts b/packages/timeline-state-resolver-types/src/generated/kairos.ts index c75e08910d..ba8f6dab33 100644 --- a/packages/timeline-state-resolver-types/src/generated/kairos.ts +++ b/packages/timeline-state-resolver-types/src/generated/kairos.ts @@ -70,41 +70,268 @@ export enum MappingKairosType { export type SomeMappingKairos = MappingKairosScene | MappingKairosSceneLayer | MappingKairosAux | MappingKairosMacro | MappingKairosClipPlayer | MappingKairosRamRecPlayer | MappingKairosStillPlayer | MappingKairosSoundPlayer -export interface ListClipsPayload { - subDirectory?: string[] +export interface ListScenesPayload { + scenePath: string[] + deep?: boolean } -export type ListClipsResult = { - name: string[] - size: number - datetime?: number - frames: number - framerate: number -}[] +export type ListScenesResult = ({ + name: string +} & SceneRef)[] -export interface ListStillsPayload { - subDirectory?: string[] +export interface SceneRef { + realm: 'scene' + scenePath: string[] } -export type ListStillsResult = { - name: string[] - size: number - datetime?: number -}[] +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 = string[] + +export interface ListMediaStillsPayload {} + +export type ListMediaStillsResult = string[] + +export interface ListMediaRamRecPayload {} + +export type ListMediaRamRecResult = string[] + +export interface ListMediaImagePayload {} + +export type ListMediaImageResult = string[] + +export interface ListMediaSoundsPayload {} + +export type ListMediaSoundsResult = 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 PlayMacroPayload { - ref?: string[] +export interface SceneSnapshotRecallPayload { + scenePath: string[] + snapshotPath: string[] } export enum KairosActions { - ListClips = 'listClips', - ListStills = 'listStills', - PlayMacro = 'playMacro' + 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.ListClips]: (payload: ListClipsPayload) => Promise>, - [KairosActions.ListStills]: (payload: ListStillsPayload) => Promise>, - [KairosActions.PlayMacro]: (payload: PlayMacroPayload) => Promise> + [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 { diff --git a/packages/timeline-state-resolver/src/integrations/kairos/$schemas/actions.json b/packages/timeline-state-resolver/src/integrations/kairos/$schemas/actions.json index ed9e3e75d2..d01c52bc3d 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/$schemas/actions.json +++ b/packages/timeline-state-resolver/src/integrations/kairos/$schemas/actions.json @@ -1,113 +1,609 @@ { - "$schema": "../../../$schemas/action-schema.json", + "$schema": "../../../../../timeline-state-resolver-api/$schemas/action-schema.json", "actions": [ { - "id": "listClips", - "name": "List clips in media folder", + "id": "listScenes", + "name": "List Scenes", "destructive": false, "payload": { "type": "object", "properties": { - "subDirectory": { - "type": "array", - "items": { - "type": "string" + "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" + ], + "additionalProperties": false + }, + { + "$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", - "properties": { - "name": { - "type": "array", - "items": { - "type": "string" - } + "allOf": [ + { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "additionalProperties": false }, - "size": { - "type": "number" + { + "$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 }, - "datetime": { - "type": "number" + { + "$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 }, - "frames": { - "type": "number" + { + "$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 }, - "framerate": { - "type": "number" + { + "$ref": "./lib/refs.json#/MatteRef" } + ] + } + } + }, + { + "id": "listMediaClips", + "name": "List Media Clips", + "destructive": false, + "payload": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "result": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "id": "listMediaStills", + "name": "List Media Stills", + "destructive": false, + "payload": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "result": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "id": "listMediaRamRec", + "name": "List Media Ram Recordings", + "destructive": false, + "payload": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "result": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "id": "listMediaImage", + "name": "List Media Images", + "destructive": false, + "payload": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "result": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "id": "listMediaSounds", + "name": "List Media Sounds", + "destructive": false, + "payload": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "result": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "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" }, - "required": [ - "name", - "size", - "dateTime", - "frames", - "framerate" - ], - "additionalProperties": false + "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": "listStills", - "name": "List stills in media folder", + "id": "listGfxScenes", + "name": "List GFX Scenes", "destructive": false, "payload": { "type": "object", "properties": { - "subDirectory": { - "type": "array", - "items": { - "type": "string" + "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", - "properties": { - "name": { - "type": "array", - "items": { - "type": "string" - } + "allOf": [ + { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "additionalProperties": false }, - "size": { - "type": "number" + { + "$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 }, - "datetime": { - "type": "number" + { + "$ref": "./lib/refs.json#/AudioMixerChannelRef" } - }, - "required": [ - "name", - "size", - ], - "additionalProperties": false + ] } } }, { - "id": "playMacro", + "id": "macroPlay", "name": "Play a macro", "destructive": false, "payload": { "type": "object", "properties": { - "ref": { - "type": "array", - "items": { - "type": "string" - } + "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/actions.ts b/packages/timeline-state-resolver/src/integrations/kairos/actions.ts new file mode 100644 index 0000000000..6ab2245ce1 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/kairos/actions.ts @@ -0,0 +1,178 @@ +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.listMediaRamRec(), + resultCode: 0, + } + }, + [KairosActions.ListMediaImage]: async (_payload) => { + return { + result: ActionExecutionResultCode.Ok, + resultData: await kairos.listMediaImage(), + 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) => { + 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/index.ts b/packages/timeline-state-resolver/src/integrations/kairos/index.ts index 0c5229761b..f691b6c5ec 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/index.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/index.ts @@ -8,7 +8,6 @@ import { SomeMappingKairos, KairosDeviceTypes, KairosActionMethods, - KairosActions, } from 'timeline-state-resolver-types' // eslint-disable-next-line node/no-missing-import import { KairosConnection } from 'kairos-connection' @@ -16,6 +15,7 @@ import type { Device, DeviceContextAPI, CommandWithContext } from 'timeline-stat import { KairosDeviceState, KairosStateBuilder } from './stateBuilder' import { diffKairosStates } from './diffState' import { sendCommand, type KairosCommandAny } from './commands' +import { getActions } from './actions' export type KairosCommandWithContext = CommandWithContext @@ -23,22 +23,12 @@ export type KairosCommandWithContext = CommandWithContext { - readonly actions: KairosActionMethods = { - [KairosActions.ListClips]: async () => { - throw new Error('Not implemented') - }, - [KairosActions.ListStills]: async () => { - throw new Error('Not implemented') - }, - [KairosActions.PlayMacro]: async () => { - throw new Error('Not implemented') - }, - } - - private readonly _kairos = new KairosConnection() + private readonly _kairos: KairosConnection + readonly actions: KairosActionMethods constructor(protected context: DeviceContextAPI) { - // Nothing + this._kairos = new KairosConnection() + this.actions = getActions(this._kairos) } /** From 5fe918680d393d7122394c03e36f97c0115d4d3c Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Thu, 9 Oct 2025 07:33:51 +0200 Subject: [PATCH 06/21] chore: doc & refactoring Co-authored-by: julusian --- .../src/integrations/kairos.ts | 3 + .../src/integrations/kairos/diffState.ts | 5 - .../src/integrations/kairos/diffState/lib.ts | 32 ++++- .../kairos/diffState/media-players.ts | 133 +++++++++--------- .../src/integrations/kairos/index.ts | 40 +----- 5 files changed, 101 insertions(+), 112 deletions(-) diff --git a/packages/timeline-state-resolver-types/src/integrations/kairos.ts b/packages/timeline-state-resolver-types/src/integrations/kairos.ts index 819a7825fa..09da2593d1 100644 --- a/packages/timeline-state-resolver-types/src/integrations/kairos.ts +++ b/packages/timeline-state-resolver-types/src/integrations/kairos.ts @@ -157,6 +157,9 @@ export interface TimelineContentKairosPlayerState /** 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 */ diff --git a/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts b/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts index a371b11757..6d2926adad 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts @@ -29,11 +29,6 @@ export function diffKairosStates( // TODO - any concerns with temporal order (ie, cutting to/from a clip player before it has started/stopped playing?) - // TODO - should this act more like atem-state where anything unset gets restored to a hardcoded 'default', or should it only set properties which are explicitly set/diff on the timeline? - // I almost did the latter, but then I realized that it would be hard to do anything. You would need to have a 'defaults' baseline object to set the default state, otherwise it wouldnt be - // repected when going between two timed objects. eg baseline: { a: 1, b: 2 }, object A: { a: 3, b: 3 } and object B: { a: 4 }. - // when going A -> B, it wouldnt know to set b to 2, because that wouldnt be present in the timeline. Depending what the property is, that may be fine or not. - commands.push(...diffSceneSnapshots(oldKairosState.sceneSnapshots, newKairosState.sceneSnapshots)) commands.push(...diffScenes(oldKairosState.scenes, newKairosState.scenes)) commands.push(...diffSceneLayers(oldKairosState.sceneLayers, newKairosState.sceneLayers)) diff --git a/packages/timeline-state-resolver/src/integrations/kairos/diffState/lib.ts b/packages/timeline-state-resolver/src/integrations/kairos/diffState/lib.ts index a901c76859..920895767a 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/diffState/lib.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/diffState/lib.ts @@ -6,7 +6,10 @@ import { isEqual } from 'underscore' * @param newObj * @returns */ -export function diffObject(oldObj: Partial | undefined, newObj: Partial | undefined): Partial | undefined { +export function diffObject( + oldObj: Partial | undefined, + newObj: Partial | undefined +): Partial | undefined { if (!newObj) return undefined const diff: Partial = {} @@ -23,6 +26,33 @@ export function diffObject(oldObj: Partial | undefined, newObj: Partial 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] 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 index 6c39f89b10..b6d1cca963 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/diffState/media-players.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/diffState/media-players.ts @@ -1,11 +1,4 @@ -import { - MediaClipRef, - MediaRamRecRef, - MediaSoundRef, - UpdateClipPlayerObject, - UpdateRamRecPlayerObject, - UpdateAudioPlayerObject, -} from 'kairos-connection' +import { MediaClipRef, MediaRamRecRef, MediaSoundRef } from 'kairos-connection' import { TimelineObjectInstance } from 'superfly-timeline' import { TimelineContentKairosPlayerState } from 'timeline-state-resolver-types' import { KairosCommandWithContext } from '..' @@ -16,7 +9,7 @@ import { KairosClipPlayerCommandMethod, } from '../commands' import { MappingOptions } from '../stateBuilder' -import { diffObject, getAllKeysString } from './lib' +import { diffObjectBoolean, getAllKeysString } from './lib' export function diffMediaPlayers( stateTime: number, @@ -26,12 +19,14 @@ export function diffMediaPlayers( ): KairosCommandWithContext[] { const commands: KairosCommandWithContext[] = [] - const keys = getAllKeysString(oldClipPlayers, newClipPlayers) - for (const key of keys) { - const keyNum = parseInt(key) + const playerIds = getAllKeysString(oldClipPlayers, newClipPlayers).map(parseInt) + for (const playerId of playerIds) { + if (isNaN(playerId)) continue - const newClipPlayer = newClipPlayers[keyNum] - const oldClipPlayer = oldClipPlayers[keyNum] + /** 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 @@ -39,15 +34,16 @@ export function diffMediaPlayers( if (!newClipPlayer && oldClipPlayer) { // ClipPlayer obj was removed, stop it: - const clearPlayerOnStop = oldClipPlayer.state.mappingOptions.clearPlayerOnStop || false + const clearPlayerOnStop = + oldClipPlayer.state.content.clearPlayerOnStop ?? oldClipPlayer.state.mappingOptions.clearPlayerOnStop ?? false if (clearPlayerOnStop) { commands.push({ timelineObjId: oldClipPlayer?.timelineObjIds.join(' & ') ?? '', - context: `key=${key} newClipPlayer=${!!newClipPlayer} oldClipPlayer=${!!oldClipPlayer}`, + context: `key=${playerId} newClipPlayer=${!!newClipPlayer} oldClipPlayer=${!!oldClipPlayer}`, command: { type: 'clip-player', - playerId: keyNum, + playerId: playerId, values: { clip: null, // Clear the clip }, @@ -56,10 +52,10 @@ export function diffMediaPlayers( } else { commands.push({ timelineObjId: oldClipPlayer?.timelineObjIds.join(' & ') ?? '', - context: `key=${key} newClipPlayer=${!!newClipPlayer} oldClipPlayer=${!!oldClipPlayer}`, + context: `key=${playerId} newClipPlayer=${!!newClipPlayer} oldClipPlayer=${!!oldClipPlayer}`, command: { type: 'clip-player:do', - playerId: keyNum, + playerId: playerId, command: 'stop', }, }) @@ -70,98 +66,98 @@ export function diffMediaPlayers( const oldState = getClipPlayerState(oldClipPlayer) const newState = getClipPlayerState(newClipPlayer) - const diff = diffObject(oldState, newState) + const diff = diffObjectBoolean(oldState, newState) if (diff) { - const cmd: Partial = {} - let doCommand: KairosClipPlayerCommandMethod['command'] | null = null + /** The properties to update on the ClipPlayer */ + const updateCmd: KairosClipPlayerCommand | KairosRamRecPlayerCommand | KairosSoundPlayerCommand = { + type: playerType, + playerId: playerId, + values: {}, + } + /** The command to be sent to the ClipPlayer */ + let playerCommand: KairosClipPlayerCommandMethod['command'] | null = null if ( - diff.seekAbsolute !== undefined && - newState.seekAbsolute !== undefined && - oldState?.seekAbsolute !== undefined && - Math.abs(newState.seekAbsolute - oldState.seekAbsolute) < 100 + 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.seekAbsolute + delete diff.absoluteStartTime } - if (playerType === 'clip-player' || playerType === 'ram-rec-player') { - // color and colorOverwrite only apply to clip-players and ram-rec-players - - const cmd0 = cmd as Partial - if (diff.color !== undefined) { - cmd0.color = diff.color + 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 !== undefined) { - cmd0.colorOverwrite = diff.colorOverwrite + if (diff.colorOverwrite) { + updateCmd.values.colorOverwrite = newState.colorOverwrite } } - if (diff.repeat !== undefined) { - cmd.repeat = diff.repeat + if (diff.repeat) { + updateCmd.values.repeat = newState.repeat ?? false } if ( // The clip has changed, trigger a play/stop command: - diff.clip !== undefined || + diff.clip || // The seek position has changed, move the playhead: - diff.seekAbsolute !== undefined + diff.absoluteStartTime ) { // This will trigger a play command below: newState.playing = newState.playing ?? false - diff.playing = newState.playing + diff.playing = true } - if (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) { - cmd.clip = newState.clip + updateCmd.values.clip = newState.clip } - if (newState.seekAbsolute !== undefined) { - const newSeek = Math.max(0, -relativeTime(stateTime, newState.seekAbsolute)) + if (newState.absoluteStartTime !== undefined) { + const newSeek = Math.max(0, -relativeTime(stateTime, newState.absoluteStartTime)) - cmd.position = Math.floor((newSeek / 1000) * framerate) + updateCmd.values.position = Math.floor((newSeek / 1000) * framerate) } - cmd.repeat = newState.repeat ?? false + updateCmd.values.repeat = newState.repeat ?? false - doCommand = 'play' - } else if (newState.playing === 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: - cmd.position = Math.floor((newState.seek / 1000) * framerate) + updateCmd.values.position = Math.floor((newState.seek / 1000) * framerate) } if (newState.clip) { - cmd.clip = newState.clip + updateCmd.values.clip = newState.clip } // Stop / Pause the clip: - if (diff.playing === false) doCommand = 'pause' + if (diff.playing) playerCommand = 'pause' } - if (!isEmptyObject(cmd)) { + if (!isEmptyObject(updateCmd.values)) { commands.push({ timelineObjId: newClipPlayer?.timelineObjIds.join(' & ') ?? '', - context: `key=${key} diff=${JSON.stringify(diff)}`, - command: { - type: playerType, - playerId: keyNum, - values: cmd as any, - }, - preliminary: doCommand === 'play' ? 40 : 0, // Send this command a bit early if there's a Play command + 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 (doCommand !== null) { + if (playerCommand !== null) { commands.push({ timelineObjId: newClipPlayer?.timelineObjIds.join(' & ') ?? '', - context: `key=${key} diff=${JSON.stringify(diff)}`, + context: `key=${playerId} diff=${JSON.stringify(diff)}`, command: { type: 'clip-player:do', - playerId: keyNum, - command: doCommand, + playerId: playerId, + command: playerCommand, }, }) } @@ -178,7 +174,8 @@ type MediaPlayerOuterState = { timelineObjIds: string[] } type MediaPlayerInnerState = MediaPlayerAny & { - seekAbsolute: number | undefined + /** Unix timestamp for at what point in time the clip starts to play */ + absoluteStartTime: number | undefined } function isEmptyObject(obj: object | undefined): boolean { @@ -204,13 +201,13 @@ function relativeTime(nowTime: number, absoluteTime: number | undefined): number function getClipPlayerState(mediaPlayer: MediaPlayerOuterState | undefined): MediaPlayerInnerState { if (mediaPlayer === undefined) return { - seekAbsolute: undefined, + absoluteStartTime: undefined, } const seek = mediaPlayer.state.content.seek const clipPlayerState: MediaPlayerInnerState = { ...mediaPlayer.state.content, - seekAbsolute: absoluteTime(mediaPlayer.state.instance, seek !== undefined ? -seek : undefined), + absoluteStartTime: absoluteTime(mediaPlayer.state.instance, seek !== undefined ? -seek : undefined), } if (clipPlayerState.playing === undefined) { @@ -221,12 +218,12 @@ function getClipPlayerState(mediaPlayer: MediaPlayerOuterState | undefined): Med // `.repeat` is defined as: // > If this is true, the actual frame position is not guaranteed (ie seek is ignored). delete clipPlayerState.seek - delete clipPlayerState.seekAbsolute + delete clipPlayerState.absoluteStartTime } if (clipPlayerState.playing === false) { // If not playing, the absolute seek position is not relevant - delete clipPlayerState.seekAbsolute + 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 index 8a8006b003..33d48ff37c 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/index.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/index.ts @@ -8,12 +8,9 @@ import { SomeMappingKairos, KairosDeviceTypes, KairosActionMethods, - KairosActions, - ListClipsResult, - ActionExecutionResultCode, } from 'timeline-state-resolver-types' // eslint-disable-next-line node/no-missing-import -import { KairosConnection, refMacro } from 'kairos-connection' +import { KairosConnection } from 'kairos-connection' import type { Device, DeviceContextAPI, CommandWithContext } from 'timeline-state-resolver-api' import { KairosDeviceState, KairosStateBuilder } from './stateBuilder' import { diffKairosStates } from './diffState' @@ -44,13 +41,6 @@ export class KairosDevice implements Device this.context.logger.error('Kairos', e)) this._kairos.on('warn', (e) => this.context.logger.warning(`Kairos: ${e?.message ?? e}`)) - // this._kairos.on('stateChanged', (state) => { - // // the external device is communicating something changed, the tracker should be updated (and may fire a "blocked" event if the change is caused by someone else) - // updateFromKairosState((addr, addrState) => this.context.setAddressState(addr, addrState), state) // note - improvement can be to update depending on the actual paths that changed - - // // old stuff for connection statuses/events: - // this._onKairosStateChanged(state) - // }) this._kairos.on('reset', () => { this.context.resetResolver() @@ -60,16 +50,8 @@ export class KairosDevice implements Device { this._connectionChanged() - // if (this._kairos.state) { - // // Do a state diff to get to the desired state - // this._protocolVersion = this._kairos.state.info.apiVersion - // this.context - // .resetToState(this._kairos.state) - // .catch((e) => this.context.logger.error('Error resetting kairos state', new Error(e))) - // } else { - // // Do a state diff to at least send all the commands we know about + // 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 @@ -87,14 +69,6 @@ export class KairosDevice implements Device { - // this.context.resetResolver() - - // return { - // result: ActionExecutionResultCode.Ok, - // } - // } - get connected(): boolean { return this._kairos.connected } @@ -158,16 +132,6 @@ export class KairosDevice implements Device Date: Wed, 15 Oct 2025 08:13:00 +0200 Subject: [PATCH 07/21] feat: implement "still-player" ie "imageStore" --- .../package.json | 2 +- .../src/generated/kairos.ts | 9 +- .../src/integrations/kairos.ts | 54 ++++++- packages/timeline-state-resolver/package.json | 2 +- .../kairos/$schemas/mappings.json | 10 +- .../kairos/__tests__/diffState.spec.ts | 2 +- .../src/integrations/kairos/__tests__/lib.ts | 2 +- .../src/integrations/kairos/commands.ts | 138 ++++++++++++------ .../src/integrations/kairos/diffState.ts | 4 +- .../kairos/diffState/media-players.ts | 138 ++++++++++++++++-- .../src/integrations/kairos/stateBuilder.ts | 47 ++++-- yarn.lock | 22 +-- 12 files changed, 331 insertions(+), 99 deletions(-) diff --git a/packages/timeline-state-resolver-types/package.json b/packages/timeline-state-resolver-types/package.json index b5c36e087a..367fc0eee3 100644 --- a/packages/timeline-state-resolver-types/package.json +++ b/packages/timeline-state-resolver-types/package.json @@ -83,7 +83,7 @@ "production" ], "dependencies": { - "kairos-lib": "0.0.1-nightly-main-20251008-111321-d929f8e", + "kairos-lib": "0.0.1-nightly-main-20251014-062622-71883bd", "tslib": "^2.6.3" }, "publishConfig": { diff --git a/packages/timeline-state-resolver-types/src/generated/kairos.ts b/packages/timeline-state-resolver-types/src/generated/kairos.ts index 9152d4cab5..de14d73bb2 100644 --- a/packages/timeline-state-resolver-types/src/generated/kairos.ts +++ b/packages/timeline-state-resolver-types/src/generated/kairos.ts @@ -51,9 +51,10 @@ export interface MappingKairosRamRecPlayer { mappingType: MappingKairosType.RamRecPlayer } -export interface MappingKairosStillPlayer { +export interface MappingKairosImageStore { playerId: number - mappingType: MappingKairosType.StillPlayer + clearPlayerOnStop?: boolean + mappingType: MappingKairosType.ImageStore } export interface MappingKairosSoundPlayer { @@ -70,11 +71,11 @@ export enum MappingKairosType { Macro = 'macro', ClipPlayer = 'clip-player', RamRecPlayer = 'ram-rec-player', - StillPlayer = 'still-player', + ImageStore = 'image-store', SoundPlayer = 'sound-player', } -export type SomeMappingKairos = MappingKairosScene | MappingKairosSceneLayer | MappingKairosAux | MappingKairosMacro | MappingKairosClipPlayer | MappingKairosRamRecPlayer | MappingKairosStillPlayer | MappingKairosSoundPlayer +export type SomeMappingKairos = MappingKairosScene | MappingKairosSceneLayer | MappingKairosAux | MappingKairosMacro | MappingKairosClipPlayer | MappingKairosRamRecPlayer | MappingKairosImageStore | MappingKairosSoundPlayer export interface ListScenesPayload { scenePath: string[] diff --git a/packages/timeline-state-resolver-types/src/integrations/kairos.ts b/packages/timeline-state-resolver-types/src/integrations/kairos.ts index 09da2593d1..b01c91d38a 100644 --- a/packages/timeline-state-resolver-types/src/integrations/kairos.ts +++ b/packages/timeline-state-resolver-types/src/integrations/kairos.ts @@ -9,6 +9,10 @@ import type { MediaSoundRef, UpdateSceneSnapshotObject, UpdateAuxObject, + MediaStillRef, + MediaImageRef, + DissolveMode, + UpdateImageStoreObject, // eslint-disable-next-line node/no-missing-import } from 'kairos-lib' @@ -21,7 +25,7 @@ export enum TimelineContentTypeKairos { CLIP_PLAYER = 'clip-player', RAMREC_PLAYER = 'ramrec-player', - STILL_PLAYER = 'still-player', + IMAGE_STORE = 'image-store', SOUND_PLAYER = 'sound-player', AUX = 'aux', @@ -42,7 +46,7 @@ export type TimelineContentKairosAny = | TimelineContentKairosMacros | TimelineContentKairosClipPlayer | TimelineContentKairosRamRecPlayer - | TimelineContentKairosStillPlayer + | TimelineContentKairosImageStore | TimelineContentKairosSoundPlayer export interface TimelineContentKairosScene { @@ -114,11 +118,43 @@ export interface TimelineContentKairosRamRecPlayer { ramRecPlayer: TimelineContentKairosPlayerState } -export interface TimelineContentKairosStillPlayer { +export interface TimelineContentKairosImageStore { deviceType: DeviceType.KAIROS - type: TimelineContentTypeKairos.STILL_PLAYER - - // stillPlayer: TimelineContentKairosPlayerState + 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 @@ -157,7 +193,11 @@ export interface TimelineContentKairosPlayerState /** 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 */ + /** + * 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 diff --git a/packages/timeline-state-resolver/package.json b/packages/timeline-state-resolver/package.json index 243a8859be..3efc1a7634 100644 --- a/packages/timeline-state-resolver/package.json +++ b/packages/timeline-state-resolver/package.json @@ -103,7 +103,7 @@ "got": "^11.8.6", "hpagent": "^1.2.0", "hyperdeck-connection": "2.0.1", - "kairos-connection": "0.0.1-nightly-main-20251008-111321-d929f8e", + "kairos-connection": "0.0.1-nightly-main-20251014-062622-71883bd", "klona": "^2.0.6", "obs-websocket-js": "^5.0.6", "osc": "^2.4.5", diff --git a/packages/timeline-state-resolver/src/integrations/kairos/$schemas/mappings.json b/packages/timeline-state-resolver/src/integrations/kairos/$schemas/mappings.json index 09547590f2..28c667efd9 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/$schemas/mappings.json +++ b/packages/timeline-state-resolver/src/integrations/kairos/$schemas/mappings.json @@ -153,7 +153,7 @@ ], "additionalProperties": false }, - "still-player": { + "image-store": { "type": "object", "properties": { "playerId": { @@ -164,6 +164,14 @@ "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": [ 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 index d696eecb36..b16f31e5ad 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/__tests__/diffState.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/__tests__/diffState.spec.ts @@ -483,5 +483,5 @@ const EMPTY_STATE: Omit = { sceneSnapshots: {}, scenes: {}, soundPlayers: {}, - stillPlayers: {}, + 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 index b6ab7f716a..24efdf15fa 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/__tests__/lib.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/__tests__/lib.ts @@ -42,5 +42,5 @@ export const EMPTY_STATE: Omit = { sceneSnapshots: {}, scenes: {}, soundPlayers: {}, - stillPlayers: {}, + imageStores: {}, } diff --git a/packages/timeline-state-resolver/src/integrations/kairos/commands.ts b/packages/timeline-state-resolver/src/integrations/kairos/commands.ts index af1e19574a..60bd1acba7 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/commands.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/commands.ts @@ -11,6 +11,7 @@ import type { KairosConnection, UpdateRamRecPlayerObject, UpdateAudioPlayerObject, + UpdateImageStoreObject, // eslint-disable-next-line node/no-missing-import } from 'kairos-connection' import { assertNever } from '../../lib' @@ -22,10 +23,10 @@ export type KairosCommandAny = | KairosAuxCommand | KairosMacroCommand | KairosClipPlayerCommand - | KairosClipPlayerCommandMethod | KairosRamRecPlayerCommand - | KairosStillPlayerCommand + | KairosImageStoreCommand | KairosSoundPlayerCommand + | KairosPlayerCommandMethod export interface KairosSceneCommand { type: 'scene' @@ -75,9 +76,10 @@ export interface KairosClipPlayerCommand { values: Partial } -export interface KairosClipPlayerCommandMethod { - type: 'clip-player:do' +export interface KairosPlayerCommandMethod { + type: 'media-player:do' playerId: number + playerType: 'clip-player' | 'ram-rec-player' | 'sound-player' command: | 'begin' | 'rewind' @@ -103,12 +105,12 @@ export interface KairosRamRecPlayerCommand { values: Partial } -export interface KairosStillPlayerCommand { - type: 'still-player' +export interface KairosImageStoreCommand { + type: 'image-store' playerId: number - values: null // TODO + values: Partial } export interface KairosSoundPlayerCommand { @@ -154,64 +156,118 @@ export async function sendCommand(kairos: KairosConnection, command: KairosComma await kairos.updateClipPlayer(command.playerId, command.values) break } - case 'clip-player:do': { + case 'media-player:do': { switch (command.command) { - case 'begin': - await kairos.clipPlayerBegin(command.playerId) + 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 if (command.playerType === 'image-store') await kairos.audioPlayerBegin(command.playerId) + else assertNever(command.playerType) break - case 'rewind': - await kairos.clipPlayerRewind(command.playerId) + } + 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': - await kairos.clipPlayerStepBack(command.playerId) + } + 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': - await kairos.clipPlayerReverse(command.playerId) + } + 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': - await kairos.clipPlayerPlay(command.playerId) + } + 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': - await kairos.clipPlayerPause(command.playerId) + } + 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': - await kairos.clipPlayerStop(command.playerId) + } + 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': - await kairos.clipPlayerStepForward(command.playerId) + } + 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': - await kairos.clipPlayerFastForward(command.playerId) + } + 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': - await kairos.clipPlayerEnd(command.playerId) + } + 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': - await kairos.clipPlayerPlaylistBegin(command.playerId) + } + 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': - await kairos.clipPlayerPlaylistBack(command.playerId) + } + 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': - await kairos.clipPlayerPlaylistNext(command.playerId) + } + 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': - await kairos.clipPlayerPlaylistEnd(command.playerId) + } + 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': await kairos.updateRamRecorder(command.playerId, command.values) break - case 'still-player': - // TODO - not implemented - // await kairos.(command.ref, command.clip) + case 'image-store': + await kairos.updateImageStore(command.playerId, command.values) break case 'sound-player': await kairos.updateAudioPlayer(command.playerId, command.values) diff --git a/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts b/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts index 6d2926adad..293cf4d203 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts @@ -5,7 +5,7 @@ import type { KairosCommandWithContext } from '.' // eslint-disable-next-line node/no-missing-import import { UpdateSceneLayerObject, UpdateSceneObject, UpdateAuxObject } from 'kairos-connection' -import { diffMediaPlayers } from './diffState/media-players' +import { diffMediaPlayers, diffMediaImageStore as diffMediaImageStore } from './diffState/media-players' import { diffObject, getAllKeysString } from './diffState/lib' export function diffKairosStates( @@ -56,7 +56,7 @@ export function diffKairosStates( newKairosState.soundPlayers ) ) - // commands.push(...diffStillPlayers(oldKairosState.stillPlayers, newKairosState.stillPlayers)) + commands.push(...diffMediaImageStore(oldKairosState.imageStores, newKairosState.imageStores)) return commands } 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 index b6d1cca963..c9e4122a7b 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/diffState/media-players.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/diffState/media-players.ts @@ -6,9 +6,10 @@ import { KairosClipPlayerCommand, KairosRamRecPlayerCommand, KairosSoundPlayerCommand, - KairosClipPlayerCommandMethod, + KairosPlayerCommandMethod, + KairosImageStoreCommand, } from '../commands' -import { MappingOptions } from '../stateBuilder' +import { KairosDeviceState, MappingOptions } from '../stateBuilder' import { diffObjectBoolean, getAllKeysString } from './lib' export function diffMediaPlayers( @@ -31,19 +32,27 @@ export function diffMediaPlayers( 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: oldClipPlayer?.timelineObjIds.join(' & ') ?? '', + timelineObjId: contextTimelineObjId, context: `key=${playerId} newClipPlayer=${!!newClipPlayer} oldClipPlayer=${!!oldClipPlayer}`, command: { - type: 'clip-player', - playerId: playerId, + ...updateCmd, values: { clip: null, // Clear the clip }, @@ -51,11 +60,12 @@ export function diffMediaPlayers( }) } else { commands.push({ - timelineObjId: oldClipPlayer?.timelineObjIds.join(' & ') ?? '', + timelineObjId: contextTimelineObjId, context: `key=${playerId} newClipPlayer=${!!newClipPlayer} oldClipPlayer=${!!oldClipPlayer}`, command: { - type: 'clip-player:do', + type: 'media-player:do', playerId: playerId, + playerType: playerType, command: 'stop', }, }) @@ -69,14 +79,8 @@ export function diffMediaPlayers( const diff = diffObjectBoolean(oldState, newState) if (diff) { - /** The properties to update on the ClipPlayer */ - const updateCmd: KairosClipPlayerCommand | KairosRamRecPlayerCommand | KairosSoundPlayerCommand = { - type: playerType, - playerId: playerId, - values: {}, - } /** The command to be sent to the ClipPlayer */ - let playerCommand: KairosClipPlayerCommandMethod['command'] | null = null + let playerCommand: KairosPlayerCommandMethod['command'] | null = null if ( diff.absoluteStartTime && @@ -155,8 +159,9 @@ export function diffMediaPlayers( timelineObjId: newClipPlayer?.timelineObjIds.join(' & ') ?? '', context: `key=${playerId} diff=${JSON.stringify(diff)}`, command: { - type: 'clip-player:do', + type: 'media-player:do', playerId: playerId, + playerType: playerType, command: playerCommand, }, }) @@ -167,6 +172,109 @@ export function diffMediaPlayers( 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(parseInt) + 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 ClipPlayer to diff + + if (!newImageStore && oldImageStore) { + // ClipPlayer 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 ClipPlayer */ + 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 diff --git a/packages/timeline-state-resolver/src/integrations/kairos/stateBuilder.ts b/packages/timeline-state-resolver/src/integrations/kairos/stateBuilder.ts index b58ce685c0..507a4f825c 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/stateBuilder.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/stateBuilder.ts @@ -16,11 +16,11 @@ import { TimelineContentKairosClipPlayer, TimelineContentKairosRamRecPlayer, TimelineContentKairosSoundPlayer, - TimelineContentKairosStillPlayer, + TimelineContentKairosImageStore, MappingKairosAux, MappingKairosClipPlayer, MappingKairosRamRecPlayer, - MappingKairosStillPlayer, + MappingKairosImageStore, MappingKairosSoundPlayer, TimelineContentKairosPlayerState, TimelineContentKairosSceneSnapshotInfo, @@ -97,9 +97,18 @@ export interface KairosDeviceState { } | undefined > - stillPlayers: Record< + imageStores: Record< number, - { ref: number; state: TimelineContentKairosStillPlayer; timelineObjIds: string[] } | undefined + | { + ref: number + state: { + content: TimelineContentKairosImageStore + mappingOptions: MappingKairosImageStore + } + + timelineObjIds: string[] + } + | undefined > soundPlayers: Record< number, @@ -127,7 +136,7 @@ export class KairosStateBuilder { macros: {}, clipPlayers: {}, ramRecPlayers: {}, - stillPlayers: {}, + imageStores: {}, soundPlayers: {}, } @@ -182,9 +191,16 @@ export class KairosStateBuilder { builder._applyRamRecPlayer(mapping.options, content, tlObject) } break - case MappingKairosType.StillPlayer: - if (content.type === TimelineContentTypeKairos.STILL_PLAYER) { - builder._applyStillPlayer(mapping.options, content, tlObject.id) + case MappingKairosType.ImageStore: + if (content.type === TimelineContentTypeKairos.IMAGE_STORE) { + builder._applyImageStore( + mapping.options, + { + content, + mappingOptions: mapping.options, + }, + tlObject.id + ) } break case MappingKairosType.SoundPlayer: @@ -354,19 +370,22 @@ export class KairosStateBuilder { ) } - private _applyStillPlayer( - mapping: MappingKairosStillPlayer, - content: TimelineContentKairosStillPlayer, + 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.stillPlayers[playerId] = this._mergeState( - this.#deviceState.stillPlayers[playerId], + this.#deviceState.imageStores[playerId] = this._mergeState( + this.#deviceState.imageStores[playerId], playerId, - content, + state, timelineObjId ) } diff --git a/yarn.lock b/yarn.lock index 44800ba26e..401409a8ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7328,22 +7328,22 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"kairos-connection@npm:0.0.1-nightly-main-20251008-111321-d929f8e": - version: 0.0.1-nightly-main-20251008-111321-d929f8e - resolution: "kairos-connection@npm:0.0.1-nightly-main-20251008-111321-d929f8e" +"kairos-connection@npm:0.0.1-nightly-main-20251014-062622-71883bd": + version: 0.0.1-nightly-main-20251014-062622-71883bd + resolution: "kairos-connection@npm:0.0.1-nightly-main-20251014-062622-71883bd" dependencies: - kairos-lib: 0.0.1-nightly-main-20251008-111321-d929f8e + kairos-lib: 0.0.1-nightly-main-20251014-062622-71883bd tslib: ^2.8.1 - checksum: 166f98e7c1db586cbeb3fc8093a96c719bb4fea8bf6babe6d4687351aa040b5cc3abfbda2ecb9f31e9c5a8db3791b2eea0a1a55cb8cc4563cf16b9f8c0170cce + checksum: dc4a697fa684a7954ea9eedd71842504da020ad1a1a12cbd018c69328979ffe0d5dcbeafacb6b29eb9e4c674db30a2d382cc346d0c3ecad769be9b24f266ab90 languageName: node linkType: hard -"kairos-lib@npm:0.0.1-nightly-main-20251008-111321-d929f8e": - version: 0.0.1-nightly-main-20251008-111321-d929f8e - resolution: "kairos-lib@npm:0.0.1-nightly-main-20251008-111321-d929f8e" +"kairos-lib@npm:0.0.1-nightly-main-20251014-062622-71883bd": + version: 0.0.1-nightly-main-20251014-062622-71883bd + resolution: "kairos-lib@npm:0.0.1-nightly-main-20251014-062622-71883bd" dependencies: tslib: ^2.8.1 - checksum: 3e856a8d8d88ecbe7bd4f6235fd4ff04cec91fe40ab2d6370428ddc0ad56faaf9d975197751df6b9a62da026db5f64b0397668f6f6713bf94eb0459fe57caced + checksum: faf75bff1a757177f4c2e0e52585c0707a50868f1ed373d1e83ab3d35336ba024bd9b4b7e235a2d0d3c2e8932933cafefac7d8b164bf2ed4467b3d24a27caf60 languageName: node linkType: hard @@ -11341,7 +11341,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: 0.0.1-nightly-main-20251008-111321-d929f8e + kairos-lib: 0.0.1-nightly-main-20251014-062622-71883bd tslib: ^2.6.3 languageName: unknown linkType: soft @@ -11366,7 +11366,7 @@ asn1@evs-broadcast/node-asn1: hyperdeck-connection: 2.0.1 jest-mock-extended: ^3.0.7 json-schema-to-typescript: ^10.1.5 - kairos-connection: 0.0.1-nightly-main-20251008-111321-d929f8e + kairos-connection: 0.0.1-nightly-main-20251014-062622-71883bd klona: ^2.0.6 obs-websocket-js: ^5.0.6 osc: ^2.4.5 From fa11e35bdbc48fe5670a89a4970a9b5f5f7f4329 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Wed, 15 Oct 2025 08:14:00 +0200 Subject: [PATCH 08/21] chore(kairos): update actions --- .../package.json | 2 +- .../src/generated/kairos.ts | 45 ++++++++-- packages/timeline-state-resolver/package.json | 2 +- .../integrations/kairos/$schemas/actions.json | 90 +++++++++++++++++-- yarn.lock | 22 ++--- 5 files changed, 138 insertions(+), 23 deletions(-) diff --git a/packages/timeline-state-resolver-types/package.json b/packages/timeline-state-resolver-types/package.json index 367fc0eee3..1c56e34531 100644 --- a/packages/timeline-state-resolver-types/package.json +++ b/packages/timeline-state-resolver-types/package.json @@ -83,7 +83,7 @@ "production" ], "dependencies": { - "kairos-lib": "0.0.1-nightly-main-20251014-062622-71883bd", + "kairos-lib": "0.0.1-nightly-main-20251015-060906-1e757e5", "tslib": "^2.6.3" }, "publishConfig": { diff --git a/packages/timeline-state-resolver-types/src/generated/kairos.ts b/packages/timeline-state-resolver-types/src/generated/kairos.ts index de14d73bb2..981c8069d3 100644 --- a/packages/timeline-state-resolver-types/src/generated/kairos.ts +++ b/packages/timeline-state-resolver-types/src/generated/kairos.ts @@ -174,23 +174,58 @@ export interface MatteRef { export interface ListMediaClipsPayload {} -export type ListMediaClipsResult = string[] +export type ListMediaClipsResult = ({ + name: string +} & MediaClipRef)[] + +export interface MediaClipRef { + realm: 'media-clip' + clipPath: string[] +} export interface ListMediaStillsPayload {} -export type ListMediaStillsResult = string[] +export type ListMediaStillsResult = ({ + name: string +} & MediaStillRef)[] + +export interface MediaStillRef { + realm: 'media-still' + clipPath: string[] +} export interface ListMediaRamRecPayload {} -export type ListMediaRamRecResult = string[] +export type ListMediaRamRecResult = ({ + name: string +} & MediaRamRecRef)[] + +export interface MediaRamRecRef { + realm: 'media-ramrec' + clipPath: string[] +} export interface ListMediaImagePayload {} -export type ListMediaImageResult = string[] +export type ListMediaImageResult = ({ + name: string +} & MediaImageRef)[] + +export interface MediaImageRef { + realm: 'media-image' + clipPath: string[] +} export interface ListMediaSoundsPayload {} -export type ListMediaSoundsResult = string[] +export type ListMediaSoundsResult = ({ + name: string +} & MediaSoundRef)[] + +export interface MediaSoundRef { + realm: 'media-sound' + clipPath: string[] +} export interface ListMacrosPayload { macroPath?: string[] diff --git a/packages/timeline-state-resolver/package.json b/packages/timeline-state-resolver/package.json index 3efc1a7634..ccb5877888 100644 --- a/packages/timeline-state-resolver/package.json +++ b/packages/timeline-state-resolver/package.json @@ -103,7 +103,7 @@ "got": "^11.8.6", "hpagent": "^1.2.0", "hyperdeck-connection": "2.0.1", - "kairos-connection": "0.0.1-nightly-main-20251014-062622-71883bd", + "kairos-connection": "0.0.1-nightly-main-20251015-060906-1e757e5", "klona": "^2.0.6", "obs-websocket-js": "^5.0.6", "osc": "^2.4.5", diff --git a/packages/timeline-state-resolver/src/integrations/kairos/$schemas/actions.json b/packages/timeline-state-resolver/src/integrations/kairos/$schemas/actions.json index d01c52bc3d..0792944531 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/$schemas/actions.json +++ b/packages/timeline-state-resolver/src/integrations/kairos/$schemas/actions.json @@ -273,7 +273,23 @@ "result": { "type": "array", "items": { - "type": "string" + "type": "object", + "allOf": [ + { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + { + "$ref": "./lib/refs.json#/MediaClipRef" + } + ] } } }, @@ -289,7 +305,23 @@ "result": { "type": "array", "items": { - "type": "string" + "type": "object", + "allOf": [ + { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + { + "$ref": "./lib/refs.json#/MediaStillRef" + } + ] } } }, @@ -305,7 +337,23 @@ "result": { "type": "array", "items": { - "type": "string" + "type": "object", + "allOf": [ + { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + { + "$ref": "./lib/refs.json#/MediaRamRecRef" + } + ] } } }, @@ -321,7 +369,23 @@ "result": { "type": "array", "items": { - "type": "string" + "type": "object", + "allOf": [ + { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + { + "$ref": "./lib/refs.json#/MediaImageRef" + } + ] } } }, @@ -337,7 +401,23 @@ "result": { "type": "array", "items": { - "type": "string" + "type": "object", + "allOf": [ + { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + { + "$ref": "./lib/refs.json#/MediaSoundRef" + } + ] } } }, diff --git a/yarn.lock b/yarn.lock index 401409a8ea..2341532a7e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7328,22 +7328,22 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"kairos-connection@npm:0.0.1-nightly-main-20251014-062622-71883bd": - version: 0.0.1-nightly-main-20251014-062622-71883bd - resolution: "kairos-connection@npm:0.0.1-nightly-main-20251014-062622-71883bd" +"kairos-connection@npm:0.0.1-nightly-main-20251015-060906-1e757e5": + version: 0.0.1-nightly-main-20251015-060906-1e757e5 + resolution: "kairos-connection@npm:0.0.1-nightly-main-20251015-060906-1e757e5" dependencies: - kairos-lib: 0.0.1-nightly-main-20251014-062622-71883bd + kairos-lib: 0.0.1-nightly-main-20251015-060906-1e757e5 tslib: ^2.8.1 - checksum: dc4a697fa684a7954ea9eedd71842504da020ad1a1a12cbd018c69328979ffe0d5dcbeafacb6b29eb9e4c674db30a2d382cc346d0c3ecad769be9b24f266ab90 + checksum: 5d071f8736a7d1196a174bc76a308c20a57372c86397481663576a02adb9e3cc670dae0c09cfcbe33be3aacc7abffa26892ee47b847189bde9585cdba90a77e4 languageName: node linkType: hard -"kairos-lib@npm:0.0.1-nightly-main-20251014-062622-71883bd": - version: 0.0.1-nightly-main-20251014-062622-71883bd - resolution: "kairos-lib@npm:0.0.1-nightly-main-20251014-062622-71883bd" +"kairos-lib@npm:0.0.1-nightly-main-20251015-060906-1e757e5": + version: 0.0.1-nightly-main-20251015-060906-1e757e5 + resolution: "kairos-lib@npm:0.0.1-nightly-main-20251015-060906-1e757e5" dependencies: tslib: ^2.8.1 - checksum: faf75bff1a757177f4c2e0e52585c0707a50868f1ed373d1e83ab3d35336ba024bd9b4b7e235a2d0d3c2e8932933cafefac7d8b164bf2ed4467b3d24a27caf60 + checksum: 112e7193d626e57328bc22e4b4bc29ce89234d0964eb1e4ccf68ddc4a784810cf6e4f66fdf1b32e9c7ccb845dc3027bd51e2842acab439f6a24d8d8589805dc5 languageName: node linkType: hard @@ -11341,7 +11341,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: 0.0.1-nightly-main-20251014-062622-71883bd + kairos-lib: 0.0.1-nightly-main-20251015-060906-1e757e5 tslib: ^2.6.3 languageName: unknown linkType: soft @@ -11366,7 +11366,7 @@ asn1@evs-broadcast/node-asn1: hyperdeck-connection: 2.0.1 jest-mock-extended: ^3.0.7 json-schema-to-typescript: ^10.1.5 - kairos-connection: 0.0.1-nightly-main-20251014-062622-71883bd + kairos-connection: 0.0.1-nightly-main-20251015-060906-1e757e5 klona: ^2.0.6 obs-websocket-js: ^5.0.6 osc: ^2.4.5 From 663605a90208c76ce2d9dd14c8218bc2197cc693 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Thu, 30 Oct 2025 10:09:43 +0100 Subject: [PATCH 09/21] chore: update kairos-connection dep --- .../package.json | 2 +- packages/timeline-state-resolver/package.json | 2 +- .../src/integrations/kairos/actions.ts | 4 ++-- yarn.lock | 22 +++++++++---------- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/timeline-state-resolver-types/package.json b/packages/timeline-state-resolver-types/package.json index 1c56e34531..7e9381d8a4 100644 --- a/packages/timeline-state-resolver-types/package.json +++ b/packages/timeline-state-resolver-types/package.json @@ -83,7 +83,7 @@ "production" ], "dependencies": { - "kairos-lib": "0.0.1-nightly-main-20251015-060906-1e757e5", + "kairos-lib": "0.2.0", "tslib": "^2.6.3" }, "publishConfig": { diff --git a/packages/timeline-state-resolver/package.json b/packages/timeline-state-resolver/package.json index ccb5877888..3f124bbbaf 100644 --- a/packages/timeline-state-resolver/package.json +++ b/packages/timeline-state-resolver/package.json @@ -103,7 +103,7 @@ "got": "^11.8.6", "hpagent": "^1.2.0", "hyperdeck-connection": "2.0.1", - "kairos-connection": "0.0.1-nightly-main-20251015-060906-1e757e5", + "kairos-connection": "0.2.0", "klona": "^2.0.6", "obs-websocket-js": "^5.0.6", "osc": "^2.4.5", diff --git a/packages/timeline-state-resolver/src/integrations/kairos/actions.ts b/packages/timeline-state-resolver/src/integrations/kairos/actions.ts index df8406bbf9..a544541dd5 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/actions.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/actions.ts @@ -85,14 +85,14 @@ export function getActions(kairos: KairosConnection): KairosActionMethods { [KairosActions.ListMediaRamRec]: async (_payload) => { return { result: ActionExecutionResultCode.Ok, - resultData: await kairos.listMediaRamRec(), + resultData: await kairos.listMediaRamRecs(), resultCode: 0, } }, [KairosActions.ListMediaImage]: async (_payload) => { return { result: ActionExecutionResultCode.Ok, - resultData: await kairos.listMediaImage(), + resultData: await kairos.listMediaImages(), resultCode: 0, } }, diff --git a/yarn.lock b/yarn.lock index 2341532a7e..4aa3884d1b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7328,22 +7328,22 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"kairos-connection@npm:0.0.1-nightly-main-20251015-060906-1e757e5": - version: 0.0.1-nightly-main-20251015-060906-1e757e5 - resolution: "kairos-connection@npm:0.0.1-nightly-main-20251015-060906-1e757e5" +"kairos-connection@npm:0.2.0": + version: 0.2.0 + resolution: "kairos-connection@npm:0.2.0" dependencies: - kairos-lib: 0.0.1-nightly-main-20251015-060906-1e757e5 + kairos-lib: 0.2.0 tslib: ^2.8.1 - checksum: 5d071f8736a7d1196a174bc76a308c20a57372c86397481663576a02adb9e3cc670dae0c09cfcbe33be3aacc7abffa26892ee47b847189bde9585cdba90a77e4 + checksum: af6fde93a8752f780ae95066265ee5138681a951939b91fcbc2056463479833a550c87b19ff66c390f75cf352060f7edada8b3e1e9883339f5cc28c22768a4fd languageName: node linkType: hard -"kairos-lib@npm:0.0.1-nightly-main-20251015-060906-1e757e5": - version: 0.0.1-nightly-main-20251015-060906-1e757e5 - resolution: "kairos-lib@npm:0.0.1-nightly-main-20251015-060906-1e757e5" +"kairos-lib@npm:0.2.0": + version: 0.2.0 + resolution: "kairos-lib@npm:0.2.0" dependencies: tslib: ^2.8.1 - checksum: 112e7193d626e57328bc22e4b4bc29ce89234d0964eb1e4ccf68ddc4a784810cf6e4f66fdf1b32e9c7ccb845dc3027bd51e2842acab439f6a24d8d8589805dc5 + checksum: ade90fb8923d450f919e985e41677610991127a0e301f4d2ec857eae10fed009d65d8b17f2d9e52c42226e6f457b57e0cd033a52509de05319c8f33f1305288b languageName: node linkType: hard @@ -11341,7 +11341,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: 0.0.1-nightly-main-20251015-060906-1e757e5 + kairos-lib: 0.2.0 tslib: ^2.6.3 languageName: unknown linkType: soft @@ -11366,7 +11366,7 @@ asn1@evs-broadcast/node-asn1: hyperdeck-connection: 2.0.1 jest-mock-extended: ^3.0.7 json-schema-to-typescript: ^10.1.5 - kairos-connection: 0.0.1-nightly-main-20251015-060906-1e757e5 + kairos-connection: 0.2.0 klona: ^2.0.6 obs-websocket-js: ^5.0.6 osc: ^2.4.5 From 29b72c3c921ffa631f017219b7dda232a616a409 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Mon, 3 Nov 2025 10:26:51 +0100 Subject: [PATCH 10/21] fix: update kairos lib --- .../timeline-state-resolver/.eslintrc.json | 8 +- .../kairos/__tests__/diffState.spec.ts | 198 ++++++++++-------- .../src/integrations/kairos/__tests__/lib.ts | 33 --- .../kairos/__tests__/stateBuilder.spec.ts | 34 +-- .../src/integrations/kairos/diffState.ts | 3 +- .../src/integrations/kairos/index.ts | 5 +- .../src/integrations/kairos/stateBuilder.ts | 21 +- yarn.lock | 13 +- 8 files changed, 162 insertions(+), 153 deletions(-) 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/src/integrations/kairos/__tests__/diffState.spec.ts b/packages/timeline-state-resolver/src/integrations/kairos/__tests__/diffState.spec.ts index b16f31e5ad..bb6e605b94 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/__tests__/diffState.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/__tests__/diffState.spec.ts @@ -8,7 +8,8 @@ import { import { KairosCommandWithContext } from '..' import { diffKairosStates } from '../diffState' import { KairosDeviceState, KairosStateBuilder } from '../stateBuilder' -import { tlObjectInstance } from './lib' +import { makeDeviceTimelineStateObject } from '../../../__mocks__/objects' +import { refIpInput } from 'kairos-connection' describe('diffState', () => { const DEFAULT_MAPPINGS: Mappings = { @@ -51,19 +52,20 @@ describe('diffState', () => { { ...EMPTY_STATE, stateTime: now }, KairosStateBuilder.fromTimeline( { - layers: { - mainSceneBackgroundLayer: tlObjectInstance(now, { - deviceType: DeviceType.KAIROS, - type: TimelineContentTypeKairos.SCENE_LAYER, - sceneLayer: { - sourcePgm: { - realm: 'input', - path: 'camera1', + objects: [ + makeDeviceTimelineStateObject({ + enable: { start: now }, + id: 'obj0', + layer: 'mainSceneBackgroundLayer', + content: { + deviceType: DeviceType.KAIROS, + type: TimelineContentTypeKairos.SCENE_LAYER, + sceneLayer: { + sourcePgm: refIpInput(1), }, }, }), - }, - nextEvents: [], + ], time: now, }, DEFAULT_MAPPINGS @@ -81,8 +83,8 @@ describe('diffState', () => { }, values: { sourcePgm: { - realm: 'input', - path: 'camera1', + realm: 'ip-input', + ipInput: 1, }, }, }, @@ -97,17 +99,21 @@ describe('diffState', () => { // Play a clip: ----------------------------------------------------------------------- let newState = KairosStateBuilder.fromTimeline( { - layers: { - clipPlayer1: tlObjectInstance(now, { - deviceType: DeviceType.KAIROS, - type: TimelineContentTypeKairos.CLIP_PLAYER, - clipPlayer: { - clip: { realm: 'media-clip', clipPath: ['amb.mp4'] }, - playing: true, + 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, + }, }, }), - }, - nextEvents: [], + ], time: now, }, DEFAULT_MAPPINGS @@ -128,14 +134,15 @@ describe('diffState', () => { repeat: false, }, }, - preliminary: 40, + preliminary: 10, }, // Play command: { context: expect.any(String), timelineObjId: expect.any(String), command: { - type: 'clip-player:do', + type: 'media-player:do', + playerType: 'clip-player', playerId: 1, command: 'play', }, @@ -151,17 +158,21 @@ describe('diffState', () => { now += 1000 newState = KairosStateBuilder.fromTimeline( { - layers: { - clipPlayer1: tlObjectInstance(now, { - deviceType: DeviceType.KAIROS, - type: TimelineContentTypeKairos.CLIP_PLAYER, - clipPlayer: { - clip: { realm: 'media-clip', clipPath: ['amb.mp4'] }, - playing: false, // Is paused + 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 + }, }, }), - }, - nextEvents: [], + ], time: now, }, DEFAULT_MAPPINGS @@ -187,7 +198,8 @@ describe('diffState', () => { context: expect.any(String), timelineObjId: expect.any(String), command: { - type: 'clip-player:do', + type: 'media-player:do', + playerType: 'clip-player', playerId: 1, command: 'pause', }, @@ -203,17 +215,21 @@ describe('diffState', () => { oldState = newState newState = KairosStateBuilder.fromTimeline( { - layers: { - clipPlayer1: tlObjectInstance(now, { - deviceType: DeviceType.KAIROS, - type: TimelineContentTypeKairos.CLIP_PLAYER, - clipPlayer: { - clip: { realm: 'media-clip', clipPath: ['amb.mp4'] }, - playing: true, // Is playing + 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 + }, }, }), - }, - nextEvents: [], + ], time: now, }, DEFAULT_MAPPINGS @@ -233,13 +249,14 @@ describe('diffState', () => { repeat: false, }, }, - preliminary: 40, + preliminary: 10, }, { context: expect.any(String), timelineObjId: expect.any(String), command: { - type: 'clip-player:do', + type: 'media-player:do', + playerType: 'clip-player', playerId: 1, command: 'play', }, @@ -255,9 +272,8 @@ describe('diffState', () => { oldState = newState newState = KairosStateBuilder.fromTimeline( { - layers: {}, + objects: [], time: now, - nextEvents: [], }, DEFAULT_MAPPINGS ) // Empty state @@ -266,7 +282,8 @@ describe('diffState', () => { context: expect.any(String), timelineObjId: expect.any(String), command: { - type: 'clip-player:do', + type: 'media-player:do', + playerType: 'clip-player', playerId: 1, command: 'stop', }, @@ -283,11 +300,15 @@ describe('diffState', () => { { ...EMPTY_STATE, stateTime: now }, KairosStateBuilder.fromTimeline( { - layers: { - clipPlayer1: tlObjectInstance( - // The clip was supposed to start 0.5s ago: - now - 500, - { + 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: { @@ -298,10 +319,9 @@ describe('diffState', () => { playing: true, seek: 100, // should start 100ms into the clip (at the point of intended start) }, - } - ), - }, - nextEvents: [], + }, + }), + ], time: now, }, DEFAULT_MAPPINGS @@ -323,14 +343,15 @@ describe('diffState', () => { position: (600 / 1000) * 25, // The result should be to seek 600ms into the clip (now - (500 + 100)) }, }, - preliminary: 40, + preliminary: 10, }, // Play command: { context: expect.any(String), timelineObjId: expect.any(String), command: { - type: 'clip-player:do', + type: 'media-player:do', + playerType: 'clip-player', playerId: 1, command: 'play', }, @@ -344,11 +365,15 @@ describe('diffState', () => { // Load a clip: ----------------------------------------------------------------------- let newState = KairosStateBuilder.fromTimeline( { - layers: { - clipPlayer1: tlObjectInstance( - // We're a bit late, should not affect the outcome though: - now - 500, - { + 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: { @@ -359,10 +384,9 @@ describe('diffState', () => { playing: false, seek: 100, // load it 100ms into the clip }, - } - ), - }, - nextEvents: [], + }, + }), + ], time: now, }, DEFAULT_MAPPINGS @@ -389,7 +413,8 @@ describe('diffState', () => { context: expect.any(String), timelineObjId: expect.any(String), command: { - type: 'clip-player:do', + type: 'media-player:do', + playerType: 'clip-player', playerId: 1, command: 'pause', }, @@ -405,21 +430,27 @@ describe('diffState', () => { oldState = newState newState = KairosStateBuilder.fromTimeline( { - layers: { - clipPlayer1: tlObjectInstance(now, { - deviceType: DeviceType.KAIROS, - type: TimelineContentTypeKairos.CLIP_PLAYER, - clipPlayer: { - clip: { - realm: 'media-clip', - clipPath: ['amb.mp4'], + 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, }, - playing: true, - seek: 100, }, }), - }, - nextEvents: [], + ], time: now, }, DEFAULT_MAPPINGS @@ -440,13 +471,14 @@ describe('diffState', () => { position: Math.floor((100 / 1000) * 25), }, }, - preliminary: 40, + preliminary: 10, }, { context: expect.any(String), timelineObjId: expect.any(String), command: { - type: 'clip-player:do', + type: 'media-player:do', + playerType: 'clip-player', playerId: 1, command: 'play', }, diff --git a/packages/timeline-state-resolver/src/integrations/kairos/__tests__/lib.ts b/packages/timeline-state-resolver/src/integrations/kairos/__tests__/lib.ts index 24efdf15fa..e5545905fd 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/__tests__/lib.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/__tests__/lib.ts @@ -1,38 +1,5 @@ -import { ResolvedTimelineObjectInstance } from 'superfly-timeline' -import { TimelineContentKairosAny, TSRTimelineContent } from 'timeline-state-resolver-types' import { KairosDeviceState } from '../stateBuilder' -/** Convenience function to convert some KAIROS content into a ResolvedTimelineObjectInstance */ -export function tlObjectInstance( - startTime: number, - content: TimelineContentKairosAny -): ResolvedTimelineObjectInstance { - return { - content, - enable: { start: startTime }, - id: 'obj0', - instance: { - id: '@obj0_instance0', - start: startTime, - end: null, - references: [], - }, - layer: 'N/A', - resolved: { - directReferences: [], - firstResolved: true, - instances: [], - isKeyframe: false, - isSelfReferencing: false, - levelDeep: 0, - parentId: undefined, - resolvedConflicts: false, - resolvedReferences: false, - resolving: false, - }, - } -} - export const EMPTY_STATE: Omit = { aux: {}, clipPlayers: {}, 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 index 4318cd9118..1dcb23440d 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/__tests__/stateBuilder.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/__tests__/stateBuilder.spec.ts @@ -6,7 +6,9 @@ import { TimelineContentTypeKairos, } from 'timeline-state-resolver-types' import { KairosStateBuilder } from '../stateBuilder' -import { EMPTY_STATE, tlObjectInstance } from './lib' +import { EMPTY_STATE } from './lib' +import { makeDeviceTimelineStateObject } from '../../../__mocks__/objects' +import { refIpInput } from 'kairos-connection' describe('stateBuilder', () => { const DEFAULT_MAPPINGS: Mappings = { @@ -32,31 +34,31 @@ describe('stateBuilder', () => { expect( KairosStateBuilder.fromTimeline( { - layers: {}, - nextEvents: [], + objects: [], time: 123, }, DEFAULT_MAPPINGS ) ).toStrictEqual({ ...EMPTY_STATE, stateTime: 123 }) }) - test('Set ', () => { + test('Set', () => { expect( KairosStateBuilder.fromTimeline( { - layers: { - mainSceneBackgroundLayer: tlObjectInstance(10000, { - deviceType: DeviceType.KAIROS, - type: TimelineContentTypeKairos.SCENE_LAYER, - sceneLayer: { - sourcePgm: { - realm: 'input', - path: 'camera1', + objects: [ + makeDeviceTimelineStateObject({ + enable: { start: 10000 }, + id: 'obj0', + layer: 'mainSceneBackgroundLayer', + content: { + deviceType: DeviceType.KAIROS, + type: TimelineContentTypeKairos.SCENE_LAYER, + sceneLayer: { + sourcePgm: refIpInput(1), }, }, }), - }, - nextEvents: [], + ], time: 0, }, DEFAULT_MAPPINGS @@ -73,8 +75,8 @@ describe('stateBuilder', () => { }, state: { sourcePgm: { - path: 'camera1', - realm: 'input', + ipInput: 1, + realm: 'ip-input', }, }, timelineObjIds: ['obj0'], diff --git a/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts b/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts index 293cf4d203..b47a81b074 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts @@ -19,8 +19,7 @@ export function diffKairosStates( KairosStateBuilder.fromTimeline( { time: 0, - layers: {}, - nextEvents: [], + objects: [], }, mappings ) diff --git a/packages/timeline-state-resolver/src/integrations/kairos/index.ts b/packages/timeline-state-resolver/src/integrations/kairos/index.ts index 33d48ff37c..290af9a4ac 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/index.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/index.ts @@ -3,7 +3,6 @@ import { StatusCode, KairosOptions, Mappings, - Timeline, TSRTimelineContent, SomeMappingKairos, KairosDeviceTypes, @@ -11,7 +10,7 @@ import { } from 'timeline-state-resolver-types' // eslint-disable-next-line node/no-missing-import import { KairosConnection } from 'kairos-connection' -import type { Device, DeviceContextAPI, CommandWithContext } from 'timeline-state-resolver-api' +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' @@ -78,7 +77,7 @@ export class KairosDevice implements Device, + timelineState: DeviceTimelineState, mappings: Mappings ): KairosDeviceState { const deviceState = KairosStateBuilder.fromTimeline(timelineState, mappings) diff --git a/packages/timeline-state-resolver/src/integrations/kairos/stateBuilder.ts b/packages/timeline-state-resolver/src/integrations/kairos/stateBuilder.ts index 507a4f825c..4071c74a57 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/stateBuilder.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/stateBuilder.ts @@ -6,7 +6,6 @@ import { TimelineContentTypeKairos, Mappings, TSRTimelineContent, - Timeline, TimelineContentKairosScene, MappingKairosScene, TimelineContentKairosSceneLayer, @@ -49,6 +48,7 @@ import { // 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 @@ -141,23 +141,16 @@ export class KairosStateBuilder { } public static fromTimeline( - timelineState: Timeline.TimelineState, + timelineState: DeviceTimelineState, mappings: Mappings ): KairosDeviceState { const builder = new KairosStateBuilder() - // Sort layer based on Layer name - const sortedLayers = Object.entries>( - timelineState.layers - ) - .map(([layerName, tlObject]) => ({ layerName, tlObject })) - .sort((a, b) => a.layerName.localeCompare(b.layerName)) - // For every layer, augment the state - for (const { tlObject, layerName } of sortedLayers) { + for (const tlObject of timelineState.objects) { const content = tlObject.content - const mapping = mappings[layerName] as Mapping | undefined + const mapping = mappings[tlObject.layer] as Mapping | undefined if (mapping && content.deviceType === DeviceType.KAIROS) { switch (mapping.options.mappingType) { @@ -325,7 +318,7 @@ export class KairosStateBuilder { private _applyClipPlayer( mapping: MappingKairosClipPlayer, content: TimelineContentKairosClipPlayer, - timelineObj: Timeline.ResolvedTimelineObjectInstance + timelineObj: DeviceTimelineStateObject ): void { if (typeof mapping.playerId !== 'number' || mapping.playerId < 1) return @@ -349,7 +342,7 @@ export class KairosStateBuilder { private _applyRamRecPlayer( mapping: MappingKairosRamRecPlayer, content: TimelineContentKairosRamRecPlayer, - timelineObj: Timeline.ResolvedTimelineObjectInstance + timelineObj: DeviceTimelineStateObject ): void { if (typeof mapping.playerId !== 'number' || mapping.playerId < 1) return @@ -393,7 +386,7 @@ export class KairosStateBuilder { private _applySoundPlayer( mapping: MappingKairosSoundPlayer, content: TimelineContentKairosSoundPlayer, - timelineObj: Timeline.ResolvedTimelineObjectInstance + timelineObj: DeviceTimelineStateObject ): void { if (typeof mapping.playerId !== 'number' || mapping.playerId < 1) return diff --git a/yarn.lock b/yarn.lock index a3f8a163d2..a63286d158 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8902,7 +8902,18 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"node-gyp-build@npm:4.6.0, node-gyp-build@npm:^4.3.0": +"node-gyp-build@npm:4.6.0": + version: 4.6.0 + resolution: "node-gyp-build@npm:4.6.0" + bin: + node-gyp-build: bin.js + node-gyp-build-optional: optional.js + node-gyp-build-test: build-test.js + checksum: 10c0/147add65942acd3cf641d11d9becd030128c7298a5b4aec4ebf3ad4afcc3d0298ad2562afba3e7b2bf70160c5e2e82235e3bc043ff9c52dc68bdd36c856764fe + languageName: node + linkType: hard + +"node-gyp-build@npm:^4.3.0": version: 4.8.4 resolution: "node-gyp-build@npm:4.8.4" bin: From d81fd8abfeb006a5df42582c53488988b367373f Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Mon, 3 Nov 2025 12:09:53 +0100 Subject: [PATCH 11/21] chore: fix kairos-lib import warnings --- .../timeline-state-resolver-types/.eslintrc.json | 14 +++++++++++++- .../src/integrations/kairos.ts | 1 - 2 files changed, 13 insertions(+), 2 deletions(-) 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/src/integrations/kairos.ts b/packages/timeline-state-resolver-types/src/integrations/kairos.ts index b01c91d38a..78d87f7c55 100644 --- a/packages/timeline-state-resolver-types/src/integrations/kairos.ts +++ b/packages/timeline-state-resolver-types/src/integrations/kairos.ts @@ -13,7 +13,6 @@ import type { MediaImageRef, DissolveMode, UpdateImageStoreObject, - // eslint-disable-next-line node/no-missing-import } from 'kairos-lib' export enum TimelineContentTypeKairos { From 75f832d345edde654f37ad55e40f1de74defe2ae Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Wed, 5 Nov 2025 14:30:58 +0100 Subject: [PATCH 12/21] fix(kairos): support fire-and-forget of SceneSnapshotRecall --- .../src/integrations/kairos/commands.ts | 10 +++++++--- .../src/integrations/kairos/diffState.ts | 4 ++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/timeline-state-resolver/src/integrations/kairos/commands.ts b/packages/timeline-state-resolver/src/integrations/kairos/commands.ts index 60bd1acba7..18186dbd1b 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/commands.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/commands.ts @@ -42,7 +42,7 @@ export interface KairosSceneRecallSnapshotCommand { ref: SceneSnapshotRef snapshotName: string - active: boolean + active: boolean | undefined } export interface KairosSceneLayerCommand { @@ -128,10 +128,14 @@ export async function sendCommand(kairos: KairosConnection, command: KairosComma await kairos.updateScene(command.ref, command.values) break case 'scene-recall-snapshot': - if (command.active) { + if (command.active === true) { await kairos.sceneSnapshotRecall(command.ref) - } else { + } 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': diff --git a/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts b/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts index b47a81b074..306899fc51 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts @@ -146,8 +146,8 @@ function diffSceneSnapshots( if (!sceneSnapshotRef) continue // No scene snapshot to diff // Check if active state changed - const oldActive = oldSceneSnapshot?.state.active ?? false - const newActive = newSceneSnapshot?.state.active ?? false + const oldActive = oldSceneSnapshot?.state.active ?? undefined + const newActive = newSceneSnapshot?.state.active ?? undefined if (oldActive !== newActive) { commands.push({ From e6fc475e750b520008516b2378a411aa5888113f Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 12 Nov 2025 09:42:41 +0000 Subject: [PATCH 13/21] chore: update kairos lib --- .../package.json | 2 +- packages/timeline-state-resolver/package.json | 2 +- yarn.lock | 22 +++++++++---------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/timeline-state-resolver-types/package.json b/packages/timeline-state-resolver-types/package.json index 7e9381d8a4..2242d0395d 100644 --- a/packages/timeline-state-resolver-types/package.json +++ b/packages/timeline-state-resolver-types/package.json @@ -83,7 +83,7 @@ "production" ], "dependencies": { - "kairos-lib": "0.2.0", + "kairos-lib": "0.2.1", "tslib": "^2.6.3" }, "publishConfig": { diff --git a/packages/timeline-state-resolver/package.json b/packages/timeline-state-resolver/package.json index 3f124bbbaf..32ce1802de 100644 --- a/packages/timeline-state-resolver/package.json +++ b/packages/timeline-state-resolver/package.json @@ -103,7 +103,7 @@ "got": "^11.8.6", "hpagent": "^1.2.0", "hyperdeck-connection": "2.0.1", - "kairos-connection": "0.2.0", + "kairos-connection": "0.2.1", "klona": "^2.0.6", "obs-websocket-js": "^5.0.6", "osc": "^2.4.5", diff --git a/yarn.lock b/yarn.lock index a63286d158..b2303071f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7767,22 +7767,22 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"kairos-connection@npm:0.2.0": - version: 0.2.0 - resolution: "kairos-connection@npm:0.2.0" +"kairos-connection@npm:0.2.1": + version: 0.2.1 + resolution: "kairos-connection@npm:0.2.1" dependencies: - kairos-lib: "npm:0.2.0" + kairos-lib: "npm:0.2.1" tslib: "npm:^2.8.1" - checksum: 10c0/4a9fe2fdbbdab7b407ae3bdddf6654870667bbb4ce7fa93b180a110935b91c587fdd5fb89005917bc7515b2e2913ca7a31207099ec0f3cfe0d34b6e346bf33b9 + checksum: 10c0/0375908079f128dac7ea183cae05b9e25db4b758efce7e2a35d7c26aa0310ea8280cb51d127b926a665851da686591100318318924c701fd8c7d7cc9fa5fdfb7 languageName: node linkType: hard -"kairos-lib@npm:0.2.0": - version: 0.2.0 - resolution: "kairos-lib@npm:0.2.0" +"kairos-lib@npm:0.2.1": + version: 0.2.1 + resolution: "kairos-lib@npm:0.2.1" dependencies: tslib: "npm:^2.8.1" - checksum: 10c0/78f078b1e370ed4909542d97036b2d8c493ec42043caecc3664bce1d4e35ba94240d4c47e92fa38509f91722976b66cf5874dd20e878eab85fafdf89dc495550 + checksum: 10c0/f3536e4225bab8004818a761a28a138de85ed6ade1d8ba88f551ecabac70c2a7877f515eb30318bd94a8db68687921d7b1e9243e436ca3a9edc423e2177bf246 languageName: node linkType: hard @@ -11748,7 +11748,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.0" + kairos-lib: "npm:0.2.1" tslib: "npm:^2.6.3" languageName: unknown linkType: soft @@ -11773,7 +11773,7 @@ asn1@evs-broadcast/node-asn1: hyperdeck-connection: "npm:2.0.1" jest-mock-extended: "npm:^3.0.7" json-schema-to-typescript: "npm:^10.1.5" - kairos-connection: "npm:0.2.0" + kairos-connection: "npm:0.2.1" klona: "npm:^2.0.6" obs-websocket-js: "npm:^5.0.6" osc: "npm:^2.4.5" From c6d8be911d98cc3563e84acad63be630313405ad Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Fri, 21 Nov 2025 07:41:52 +0100 Subject: [PATCH 14/21] chore: update kairos-connection dep --- .../package.json | 2 +- packages/timeline-state-resolver/package.json | 2 +- yarn.lock | 22 +++++++++---------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/timeline-state-resolver-types/package.json b/packages/timeline-state-resolver-types/package.json index 2242d0395d..3eafd6ee4f 100644 --- a/packages/timeline-state-resolver-types/package.json +++ b/packages/timeline-state-resolver-types/package.json @@ -83,7 +83,7 @@ "production" ], "dependencies": { - "kairos-lib": "0.2.1", + "kairos-lib": "0.2.2", "tslib": "^2.6.3" }, "publishConfig": { diff --git a/packages/timeline-state-resolver/package.json b/packages/timeline-state-resolver/package.json index 32ce1802de..a36a9e6524 100644 --- a/packages/timeline-state-resolver/package.json +++ b/packages/timeline-state-resolver/package.json @@ -103,7 +103,7 @@ "got": "^11.8.6", "hpagent": "^1.2.0", "hyperdeck-connection": "2.0.1", - "kairos-connection": "0.2.1", + "kairos-connection": "0.2.2", "klona": "^2.0.6", "obs-websocket-js": "^5.0.6", "osc": "^2.4.5", diff --git a/yarn.lock b/yarn.lock index b2303071f7..64c63c0cb2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7767,22 +7767,22 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"kairos-connection@npm:0.2.1": - version: 0.2.1 - resolution: "kairos-connection@npm:0.2.1" +"kairos-connection@npm:0.2.2": + version: 0.2.2 + resolution: "kairos-connection@npm:0.2.2" dependencies: - kairos-lib: "npm:0.2.1" + kairos-lib: "npm:0.2.2" tslib: "npm:^2.8.1" - checksum: 10c0/0375908079f128dac7ea183cae05b9e25db4b758efce7e2a35d7c26aa0310ea8280cb51d127b926a665851da686591100318318924c701fd8c7d7cc9fa5fdfb7 + checksum: 10c0/55b0ba0fb6a11a43601d7fcc2f186535cea69b53c76d986ceec0011cac6ed66687e980b03672ee8cc1bced2f882b5da3f65693f3f9ad9c5295e4881838cf7f8d languageName: node linkType: hard -"kairos-lib@npm:0.2.1": - version: 0.2.1 - resolution: "kairos-lib@npm:0.2.1" +"kairos-lib@npm:0.2.2": + version: 0.2.2 + resolution: "kairos-lib@npm:0.2.2" dependencies: tslib: "npm:^2.8.1" - checksum: 10c0/f3536e4225bab8004818a761a28a138de85ed6ade1d8ba88f551ecabac70c2a7877f515eb30318bd94a8db68687921d7b1e9243e436ca3a9edc423e2177bf246 + checksum: 10c0/895f4253859b3d6cfc1ba36127052ed13b290c9dda31fa182eb68c8697627e4d2d906f6ae1470982668a90b5cd04d9d3ad90676feb5090305d654a2cc07dd04f languageName: node linkType: hard @@ -11748,7 +11748,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.1" + kairos-lib: "npm:0.2.2" tslib: "npm:^2.6.3" languageName: unknown linkType: soft @@ -11773,7 +11773,7 @@ asn1@evs-broadcast/node-asn1: hyperdeck-connection: "npm:2.0.1" jest-mock-extended: "npm:^3.0.7" json-schema-to-typescript: "npm:^10.1.5" - kairos-connection: "npm:0.2.1" + kairos-connection: "npm:0.2.2" klona: "npm:^2.0.6" obs-websocket-js: "npm:^5.0.6" osc: "npm:^2.4.5" From 9509c499c469fe854b910705efd6ffbb724071dc Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Tue, 25 Nov 2025 16:32:49 +0100 Subject: [PATCH 15/21] fix(kairos): if theres an Error when using a still/ramrec, try to LOAD it and try again --- .../timeline-state-resolver-api/src/device.ts | 8 + .../package.json | 2 +- packages/timeline-state-resolver/package.json | 2 +- .../src/integrations/kairos/commands.ts | 171 +++++++++++++++--- .../src/integrations/kairos/diffState.ts | 1 + .../src/integrations/kairos/index.ts | 8 +- .../kairos/lib/kairosRamLoader.ts | 113 ++++++++++++ .../src/service/DeviceInstance.ts | 11 +- .../src/service/stateHandler.ts | 4 + yarn.lock | 22 +-- 10 files changed, 300 insertions(+), 42 deletions(-) create mode 100644 packages/timeline-state-resolver/src/integrations/kairos/lib/kairosRamLoader.ts diff --git a/packages/timeline-state-resolver-api/src/device.ts b/packages/timeline-state-resolver-api/src/device.ts index 44c5889495..77c3348749 100644 --- a/packages/timeline-state-resolver-api/src/device.ts +++ b/packages/timeline-state-resolver-api/src/device.ts @@ -243,6 +243,14 @@ export interface DeviceContextAPI { /** Reset the tracked device state to "state" and notify the conductor to reset the resolver */ resetToState: (state: DeviceState) => Promise + /** + * Reset the tracked device state to "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-types/package.json b/packages/timeline-state-resolver-types/package.json index 3eafd6ee4f..1fadfcb661 100644 --- a/packages/timeline-state-resolver-types/package.json +++ b/packages/timeline-state-resolver-types/package.json @@ -83,7 +83,7 @@ "production" ], "dependencies": { - "kairos-lib": "0.2.2", + "kairos-lib": "0.2.3", "tslib": "^2.6.3" }, "publishConfig": { diff --git a/packages/timeline-state-resolver/package.json b/packages/timeline-state-resolver/package.json index a36a9e6524..b9536d8671 100644 --- a/packages/timeline-state-resolver/package.json +++ b/packages/timeline-state-resolver/package.json @@ -103,7 +103,7 @@ "got": "^11.8.6", "hpagent": "^1.2.0", "hyperdeck-connection": "2.0.1", - "kairos-connection": "0.2.2", + "kairos-connection": "0.2.3", "klona": "^2.0.6", "obs-websocket-js": "^5.0.6", "osc": "^2.4.5", diff --git a/packages/timeline-state-resolver/src/integrations/kairos/commands.ts b/packages/timeline-state-resolver/src/integrations/kairos/commands.ts index 18186dbd1b..9c917be353 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/commands.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/commands.ts @@ -1,20 +1,23 @@ -import type { - UpdateSceneObject, - UpdateSceneLayerObject, - UpdateAuxObject, - UpdateClipPlayerObject, - SceneRef, - SceneLayerRef, - AuxRef, - MacroRef, - SceneSnapshotRef, - KairosConnection, - UpdateRamRecPlayerObject, - UpdateAudioPlayerObject, - UpdateImageStoreObject, +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 { KairosDevice } from '.' +import { isEqual } from 'underscore' export type KairosCommandAny = | KairosSceneCommand @@ -49,6 +52,7 @@ export interface KairosSceneLayerCommand { type: 'scene-layer' ref: SceneLayerRef + sceneLayerId: string values: Partial } @@ -121,7 +125,11 @@ export interface KairosSoundPlayerCommand { values: Partial } -export async function sendCommand(kairos: KairosConnection, command: KairosCommandAny): Promise { +export async function sendCommand( + device: KairosDevice, + kairos: KairosConnection, + command: KairosCommandAny +): Promise { const commandType = command.type switch (command.type) { case 'scene': @@ -138,9 +146,66 @@ export async function sendCommand(kairos: KairosConnection, command: KairosComma assertNever(command.active) } break - case 'scene-layer': - await kairos.updateSceneLayer(command.ref, command.values) + 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 + try { + await kairos.updateSceneLayer(command.ref, { sourceA: source }) + } catch (e) { + await device.kairosRamLoader.handleFailedRAMLoad(e, -1, 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 still: + 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 + try { + await kairos.updateSceneLayer(command.ref, { sourcePgm: source }) + } catch (e) { + await device.kairosRamLoader.handleFailedRAMLoad(e, -1, 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 + try { + await kairos.updateSceneLayer(command.ref, { sourcePst: source }) + } catch (e) { + await device.kairosRamLoader.handleFailedRAMLoad(e, -1, 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 @@ -152,12 +217,13 @@ export async function sendCommand(kairos: KairosConnection, command: KairosComma } break case 'clip-player': { - if (command.values.clip) { - await kairos.loadClipPlayerClip(command.playerId, command.values.clip, command.values.position) - delete command.values.clip - delete command.values.position + 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, command.values) + await kairos.updateClipPlayer(command.playerId, values) break } case 'media-player:do': { @@ -267,12 +333,65 @@ export async function sendCommand(kairos: KairosConnection, command: KairosComma } break } - case 'ram-rec-player': - await kairos.updateRamRecorder(command.playerId, command.values) + 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 + try { + await kairos.updateRamRecorder(command.playerId, { + clip: clip, + }) + } catch (e) { + await device.kairosRamLoader.handleFailedRAMLoad(e, 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 + return currentState + } + + return false + }) + } + } + + await kairos.updateRamRecorder(command.playerId, values) break - case 'image-store': - await kairos.updateImageStore(command.playerId, command.values) + } + 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 + try { + await kairos.updateImageStore(command.playerId, { + clip: clip, + }) + } catch (e) { + if (isRef(clip) && clip.realm !== 'media-still') throw e // Not a still, re-throw + + await device.kairosRamLoader.handleFailedRAMLoad(e, 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 + return currentState + } + return false + }) + } + } + + await kairos.updateImageStore(command.playerId, values) break + } case 'sound-player': await kairos.updateAudioPlayer(command.playerId, command.values) break diff --git a/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts b/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts index 306899fc51..a26192869c 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts @@ -122,6 +122,7 @@ function diffSceneLayers( command: { type: 'scene-layer', ref: sceneLayerRef, + sceneLayerId: sceneLayerKey, values: diff, }, }) diff --git a/packages/timeline-state-resolver/src/integrations/kairos/index.ts b/packages/timeline-state-resolver/src/integrations/kairos/index.ts index 290af9a4ac..2449342610 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/index.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/index.ts @@ -15,6 +15,7 @@ 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 @@ -25,9 +26,12 @@ export class KairosDevice implements Device) { + public kairosRamLoader: KairosRamLoader + + constructor(public context: DeviceContextAPI) { this._kairos = new KairosConnection() this.actions = getActions(this._kairos) + this.kairosRamLoader = new KairosRamLoader(this._kairos, context) } /** @@ -125,7 +129,7 @@ export class KairosDevice implements Device() + + constructor(private kairos: KairosConnection, private context: DeviceContextAPI) {} + + async handleFailedRAMLoad( + originalError: unknown, + playerId: number, + source: AnySourceRef | string, + + afterLoadSetModifiedStateCallback: (currentState: KairosDeviceState) => KairosDeviceState | false + ): Promise { + // This method is called whenever there was an "Error" in reply to using a ramrec/still. + // This can happen if the ramrec/still is not loaded into RAM. + // As a fallback, we try to load the ramrec into RAM, so that it can be played. + + // First, check if the thrown error is indeed a ResponseError + if (!(originalError instanceof ResponseError)) throw originalError // Not a ResponseError, re-throw it + + const clipRef = typeof source === 'string' ? pathToRef(source) : source + if (!isRef(clipRef) || (clipRef.realm !== 'media-still' && clipRef.realm !== 'media-ramrec')) throw originalError // Not a valid ramrec/still ref, re-throw the original error + + 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) + // throw new Error( + // `Error when load ${identifier}, it is already loaded. Original Error: ${originalError.message} ${ + // originalError.stack + // }` + // ) + + // 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 ramrec into RAM + }) + } + } + + // Ensure that we're not already waiting for this clip to load: + const debounceKey = `${identifier}_${playerId}` + 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/service/DeviceInstance.ts b/packages/timeline-state-resolver/src/service/DeviceInstance.ts index ac71cd6308..afbc594065 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 = require('eventemitter3') -import { actionNotFoundMessage } from '../lib' +import { actionNotFoundMessage, cloneDeep } from '../lib' import type { FinishedTrace, DeviceEntry, @@ -320,7 +320,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/stateHandler.ts b/packages/timeline-state-resolver/src/service/stateHandler.ts index c3008ef899..3e6c655fac 100644 --- a/packages/timeline-state-resolver/src/service/stateHandler.ts +++ b/packages/timeline-state-resolver/src/service/stateHandler.ts @@ -160,6 +160,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/yarn.lock b/yarn.lock index 64c63c0cb2..0683879e18 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7767,22 +7767,22 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"kairos-connection@npm:0.2.2": - version: 0.2.2 - resolution: "kairos-connection@npm:0.2.2" +"kairos-connection@npm:0.2.3": + version: 0.2.3 + resolution: "kairos-connection@npm:0.2.3" dependencies: - kairos-lib: "npm:0.2.2" + kairos-lib: "npm:0.2.3" tslib: "npm:^2.8.1" - checksum: 10c0/55b0ba0fb6a11a43601d7fcc2f186535cea69b53c76d986ceec0011cac6ed66687e980b03672ee8cc1bced2f882b5da3f65693f3f9ad9c5295e4881838cf7f8d + checksum: 10c0/461801d6cc11bb6e6594fc2067b18c6dd9359d52afb56f854da995d209c3f4675d03f0c4eff1f24007a05a4ee40b6cd367aa1f6e473cafed1c5460fe3b4d0b1a languageName: node linkType: hard -"kairos-lib@npm:0.2.2": - version: 0.2.2 - resolution: "kairos-lib@npm:0.2.2" +"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/895f4253859b3d6cfc1ba36127052ed13b290c9dda31fa182eb68c8697627e4d2d906f6ae1470982668a90b5cd04d9d3ad90676feb5090305d654a2cc07dd04f + checksum: 10c0/d40347cc5f3b00a10acee99156b54e2fe742dc5d80def3749f5096b2c8da5b03491c7ac87172421684846debb13cbc4f1f2a3283debb6b95b3d7b94e64514db1 languageName: node linkType: hard @@ -11748,7 +11748,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.2" + kairos-lib: "npm:0.2.3" tslib: "npm:^2.6.3" languageName: unknown linkType: soft @@ -11773,7 +11773,7 @@ asn1@evs-broadcast/node-asn1: hyperdeck-connection: "npm:2.0.1" jest-mock-extended: "npm:^3.0.7" json-schema-to-typescript: "npm:^10.1.5" - kairos-connection: "npm:0.2.2" + kairos-connection: "npm:0.2.3" klona: "npm:^2.0.6" obs-websocket-js: "npm:^5.0.6" osc: "npm:^2.4.5" From f5d2f5a3bf9070c91f198f5e4efe0c7dd282c1fa Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Wed, 26 Nov 2025 08:30:09 +0100 Subject: [PATCH 16/21] fix: load in RAM. It turns out that we _dont_ get an Error in response when trying to use a non-RAM-loaded ramrec/still --- .../src/integrations/kairos/commands.ts | 127 +++++++++--------- .../kairos/lib/kairosRamLoader.ts | 36 +++-- 2 files changed, 80 insertions(+), 83 deletions(-) diff --git a/packages/timeline-state-resolver/src/integrations/kairos/commands.ts b/packages/timeline-state-resolver/src/integrations/kairos/commands.ts index 9c917be353..9cc435261b 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/commands.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/commands.ts @@ -152,55 +152,52 @@ export async function sendCommand( // Handle loading ramrec/still into RAM if needed const source = values.sourceA delete values.sourceA - try { - await kairos.updateSceneLayer(command.ref, { sourceA: source }) - } catch (e) { - await device.kairosRamLoader.handleFailedRAMLoad(e, -1, 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 still: - delete sceneLayer.state.sourceA - return currentState - } else return false - }) - } + + await kairos.updateSceneLayer(command.ref, { sourceA: source }) + + await device.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 - try { - await kairos.updateSceneLayer(command.ref, { sourcePgm: source }) - } catch (e) { - await device.kairosRamLoader.handleFailedRAMLoad(e, -1, 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 - }) - } + + await kairos.updateSceneLayer(command.ref, { sourcePgm: source }) + + await device.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 - try { - await kairos.updateSceneLayer(command.ref, { sourcePst: source }) - } catch (e) { - await device.kairosRamLoader.handleFailedRAMLoad(e, -1, 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, { sourcePst: source }) + + await device.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) @@ -339,24 +336,24 @@ export async function sendCommand( // Handle loading ramrec/still into RAM if needed const clip = values.clip delete values.clip - try { - await kairos.updateRamRecorder(command.playerId, { - clip: clip, - }) - } catch (e) { - await device.kairosRamLoader.handleFailedRAMLoad(e, 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 - return currentState - } - return false - }) - } + await kairos.updateRamRecorder(command.playerId, { + clip: clip, + }) + + await device.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) @@ -368,20 +365,22 @@ export async function sendCommand( // Handle loading ramrec/still into RAM if needed const clip = values.clip delete values.clip - try { - await kairos.updateImageStore(command.playerId, { - clip: clip, - }) - } catch (e) { - if (isRef(clip) && clip.realm !== 'media-still') throw e // Not a still, re-throw - await device.kairosRamLoader.handleFailedRAMLoad(e, command.playerId, clip, (currentState) => { + await kairos.updateImageStore(command.playerId, { + clip: clip, + }) + + if (isRef(clip) && clip.realm === 'media-still') { + // type guard + + await device.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 + // player.state.content.imageStore.clip = null + delete currentState.imageStores[command.playerId] return currentState } return false diff --git a/packages/timeline-state-resolver/src/integrations/kairos/lib/kairosRamLoader.ts b/packages/timeline-state-resolver/src/integrations/kairos/lib/kairosRamLoader.ts index 9b737b6907..bf56a67b7d 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/lib/kairosRamLoader.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/lib/kairosRamLoader.ts @@ -7,7 +7,6 @@ import { MediaStillRef, pathToRef, refToPath, - ResponseError, } from 'kairos-connection' import { KairosDeviceState } from '../stateBuilder' import { DeviceContextAPI } from 'timeline-state-resolver-api' @@ -17,22 +16,23 @@ export class KairosRamLoader { constructor(private kairos: KairosConnection, private context: DeviceContextAPI) {} - async handleFailedRAMLoad( - originalError: unknown, - playerId: number, + async ensureRAMLoaded( + playerIdentifier: number | string, source: AnySourceRef | string, afterLoadSetModifiedStateCallback: (currentState: KairosDeviceState) => KairosDeviceState | false ): Promise { - // This method is called whenever there was an "Error" in reply to using a ramrec/still. - // This can happen if the ramrec/still is not loaded into RAM. - // As a fallback, we try to load the ramrec into RAM, so that it can be played. - - // First, check if the thrown error is indeed a ResponseError - if (!(originalError instanceof ResponseError)) throw originalError // Not a ResponseError, re-throw it + // 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')) throw originalError // Not a valid ramrec/still ref, re-throw the original error + 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)}` @@ -45,12 +45,10 @@ export class KairosRamLoader { if (media.status === MediaStatus.ERROR) throw new Error(`Cannot load ${identifier}: status is ERROR`) - // if (media.status === MediaStatus.LOAD && media.loadProgress === 1) - // throw new Error( - // `Error when load ${identifier}, it is already loaded. Original Error: ${originalError.message} ${ - // originalError.stack - // }` - // ) + 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() @@ -63,13 +61,13 @@ export class KairosRamLoader { }) } else { await this.kairos.updateMediaStill(clipRef, { - status: MediaStatus.LOAD, // Load the ramrec into RAM + status: MediaStatus.LOAD, // Load the still into RAM }) } } // Ensure that we're not already waiting for this clip to load: - const debounceKey = `${identifier}_${playerId}` + const debounceKey = `${identifier}_${playerIdentifier}` if (this.debounceTrackLoadRAM.has(debounceKey)) return // Already waiting for this clip to load this.debounceTrackLoadRAM.add(debounceKey) From e206c16bdee89e7acbb802dc19007aef5de61d1d Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Wed, 26 Nov 2025 08:37:33 +0100 Subject: [PATCH 17/21] chore: fix unit test --- .../src/integrations/__tests__/testlib.ts | 1 + .../src/integrations/kairos/__tests__/diffState.spec.ts | 1 + 2 files changed, 2 insertions(+) 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/kairos/__tests__/diffState.spec.ts b/packages/timeline-state-resolver/src/integrations/kairos/__tests__/diffState.spec.ts index bb6e605b94..6b9cce06b1 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/__tests__/diffState.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/__tests__/diffState.spec.ts @@ -76,6 +76,7 @@ describe('diffState', () => { timelineObjId: expect.any(String), command: { type: 'scene-layer', + sceneLayerId: 'SCENES.Main.Layers.Background', ref: { realm: 'scene-layer', scenePath: ['Main'], From d353e077a9eb4b21272862f13cd4596757c024b8 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 10 Dec 2025 14:01:16 +0000 Subject: [PATCH 18/21] fix: avoid cyclical import --- .../src/integrations/kairos/commands.ts | 14 +++++++------- .../src/integrations/kairos/index.ts | 8 ++++---- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/timeline-state-resolver/src/integrations/kairos/commands.ts b/packages/timeline-state-resolver/src/integrations/kairos/commands.ts index 9cc435261b..b063d4c12d 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/commands.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/commands.ts @@ -16,8 +16,8 @@ import { // eslint-disable-next-line node/no-missing-import } from 'kairos-connection' import { assertNever } from '../../lib' -import { KairosDevice } from '.' import { isEqual } from 'underscore' +import type { KairosRamLoader } from './lib/kairosRamLoader' export type KairosCommandAny = | KairosSceneCommand @@ -126,8 +126,8 @@ export interface KairosSoundPlayerCommand { } export async function sendCommand( - device: KairosDevice, kairos: KairosConnection, + kairosRamLoader: KairosRamLoader, command: KairosCommandAny ): Promise { const commandType = command.type @@ -155,7 +155,7 @@ export async function sendCommand( await kairos.updateSceneLayer(command.ref, { sourceA: source }) - await device.kairosRamLoader.ensureRAMLoaded(`sceneLayer_${command.sceneLayerId}`, source, (currentState) => { + 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)) { @@ -172,7 +172,7 @@ export async function sendCommand( await kairos.updateSceneLayer(command.ref, { sourcePgm: source }) - await device.kairosRamLoader.ensureRAMLoaded(`sceneLayer_${command.sceneLayerId}`, source, (currentState) => { + 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)) { @@ -189,7 +189,7 @@ export async function sendCommand( await kairos.updateSceneLayer(command.ref, { sourcePst: source }) - await device.kairosRamLoader.ensureRAMLoaded(`sceneLayer_${command.sceneLayerId}`, source, (currentState) => { + 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)) { @@ -341,7 +341,7 @@ export async function sendCommand( clip: clip, }) - await device.kairosRamLoader.ensureRAMLoaded(command.playerId, clip, (currentState) => { + await kairosRamLoader.ensureRAMLoaded(command.playerId, clip, (currentState) => { // This is called after the RAM load is done, // const player = currentState.ramRecPlayers[command.playerId] @@ -373,7 +373,7 @@ export async function sendCommand( if (isRef(clip) && clip.realm === 'media-still') { // type guard - await device.kairosRamLoader.ensureRAMLoaded(command.playerId, clip, (currentState) => { + await kairosRamLoader.ensureRAMLoaded(command.playerId, clip, (currentState) => { // This is called after the RAM load is done, // const player = currentState.imageStores[command.playerId] diff --git a/packages/timeline-state-resolver/src/integrations/kairos/index.ts b/packages/timeline-state-resolver/src/integrations/kairos/index.ts index 2449342610..78f7120577 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/index.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/index.ts @@ -24,14 +24,14 @@ export type KairosCommandWithContext = CommandWithContext { private readonly _kairos: KairosConnection - readonly actions: KairosActionMethods + private readonly _kairosRamLoader: KairosRamLoader - public 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) - this.kairosRamLoader = new KairosRamLoader(this._kairos, context) } /** @@ -129,7 +129,7 @@ export class KairosDevice implements Device Date: Wed, 12 Nov 2025 11:40:00 +0000 Subject: [PATCH 19/21] fix: kairos media player support --- .../src/integrations/kairos/diffState/media-players.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index c9e4122a7b..158c835a01 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/diffState/media-players.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/diffState/media-players.ts @@ -20,7 +20,7 @@ export function diffMediaPlayers( ): KairosCommandWithContext[] { const commands: KairosCommandWithContext[] = [] - const playerIds = getAllKeysString(oldClipPlayers, newClipPlayers).map(parseInt) + const playerIds = getAllKeysString(oldClipPlayers, newClipPlayers).map((v) => parseInt(v)) for (const playerId of playerIds) { if (isNaN(playerId)) continue @@ -180,7 +180,7 @@ export function diffMediaImageStore( // 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(parseInt) + const playerIds = getAllKeysString(oldImageStores, newImageStores).map((v) => parseInt(v)) for (const playerId of playerIds) { if (isNaN(playerId)) continue From 450c7c5c08156a8445ac73cb3f87804da4badb6f Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 12 Nov 2025 13:39:51 +0000 Subject: [PATCH 20/21] fix: kairos clip lookaheads are paused with correct seek --- .../src/integrations/kairos/stateBuilder.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/timeline-state-resolver/src/integrations/kairos/stateBuilder.ts b/packages/timeline-state-resolver/src/integrations/kairos/stateBuilder.ts index 4071c74a57..25c494d4f8 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/stateBuilder.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/stateBuilder.ts @@ -328,7 +328,7 @@ export class KairosStateBuilder { this.#deviceState.clipPlayers[playerId], playerId, { - content: content.clipPlayer, + content: patchPlayerStateForLookahead(content.clipPlayer, timelineObj.isLookahead), instance: timelineObj.instance, mappingOptions: { framerate: mapping.framerate, @@ -352,7 +352,7 @@ export class KairosStateBuilder { this.#deviceState.ramRecPlayers[playerId], playerId, { - content: content.ramRecPlayer, + content: patchPlayerStateForLookahead(content.ramRecPlayer, timelineObj.isLookahead), instance: timelineObj.instance, mappingOptions: { framerate: mapping.framerate, @@ -396,7 +396,7 @@ export class KairosStateBuilder { this.#deviceState.soundPlayers[playerId], playerId, { - content: content.soundPlayer, + content: patchPlayerStateForLookahead(content.soundPlayer, timelineObj.isLookahead), instance: timelineObj.instance, mappingOptions: { framerate: mapping.framerate, @@ -411,3 +411,18 @@ 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, + } +} From d60386e68b3f5bbec3ec87492c96c734eec34c3e Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 17 Dec 2025 15:38:42 +0000 Subject: [PATCH 21/21] chore: review comments --- .../timeline-state-resolver-api/src/device.ts | 2 +- .../integrations/kairos/$schemas/actions.json | 109 +++++------------- .../integrations/kairos/$schemas/options.json | 2 +- .../src/integrations/kairos/commands.ts | 3 +- .../src/integrations/kairos/diffState.ts | 2 +- .../src/integrations/kairos/diffState/lib.ts | 3 +- .../kairos/diffState/media-players.ts | 6 +- 7 files changed, 35 insertions(+), 92 deletions(-) diff --git a/packages/timeline-state-resolver-api/src/device.ts b/packages/timeline-state-resolver-api/src/device.ts index 77c3348749..5cd89d7794 100644 --- a/packages/timeline-state-resolver-api/src/device.ts +++ b/packages/timeline-state-resolver-api/src/device.ts @@ -244,7 +244,7 @@ export interface DeviceContextAPI { resetToState: (state: DeviceState) => Promise /** - * Reset the tracked device state to "state" and notify the conductor to reset the resolver + * 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. diff --git a/packages/timeline-state-resolver/src/integrations/kairos/$schemas/actions.json b/packages/timeline-state-resolver/src/integrations/kairos/$schemas/actions.json index 0792944531..987c4313a1 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/$schemas/actions.json +++ b/packages/timeline-state-resolver/src/integrations/kairos/$schemas/actions.json @@ -15,9 +15,7 @@ "type": "boolean" } }, - "required": [ - "scenePath" - ], + "required": ["scenePath"], "additionalProperties": false }, "result": { @@ -31,10 +29,7 @@ "type": "string" } }, - "required": [ - "name" - ], - "additionalProperties": false + "required": ["name"] }, { "$ref": "./lib/refs.json#/SceneRef" @@ -60,10 +55,7 @@ "type": "boolean" } }, - "required": [ - "scenePath", - "layerPath" - ], + "required": ["scenePath", "layerPath"], "additionalProperties": false }, "result": { @@ -77,9 +69,7 @@ "type": "string" } }, - "required": [ - "name" - ], + "required": ["name"], "additionalProperties": false }, { @@ -106,10 +96,7 @@ "type": "boolean" } }, - "required": [ - "scenePath", - "layerPath" - ], + "required": ["scenePath", "layerPath"], "additionalProperties": false }, "result": { @@ -123,9 +110,7 @@ "type": "string" } }, - "required": [ - "name" - ], + "required": ["name"], "additionalProperties": false }, { @@ -146,9 +131,7 @@ "$ref": "./lib/refs.json#/SceneRef/properties/scenePath" } }, - "required": [ - "scenePath" - ], + "required": ["scenePath"], "additionalProperties": false }, "result": { @@ -169,9 +152,7 @@ "$ref": "./lib/refs.json#/SceneRef/properties/scenePath" } }, - "required": [ - "scenePath" - ], + "required": ["scenePath"], "additionalProperties": false }, "result": { @@ -185,9 +166,7 @@ "type": "string" } }, - "required": [ - "name" - ], + "required": ["name"], "additionalProperties": false }, { @@ -217,9 +196,7 @@ "type": "string" } }, - "required": [ - "name" - ], + "required": ["name"], "additionalProperties": false }, { @@ -249,9 +226,7 @@ "type": "string" } }, - "required": [ - "name" - ], + "required": ["name"], "additionalProperties": false }, { @@ -281,9 +256,7 @@ "type": "string" } }, - "required": [ - "name" - ], + "required": ["name"], "additionalProperties": false }, { @@ -313,9 +286,7 @@ "type": "string" } }, - "required": [ - "name" - ], + "required": ["name"], "additionalProperties": false }, { @@ -345,9 +316,7 @@ "type": "string" } }, - "required": [ - "name" - ], + "required": ["name"], "additionalProperties": false }, { @@ -377,9 +346,7 @@ "type": "string" } }, - "required": [ - "name" - ], + "required": ["name"], "additionalProperties": false }, { @@ -409,9 +376,7 @@ "type": "string" } }, - "required": [ - "name" - ], + "required": ["name"], "additionalProperties": false }, { @@ -448,9 +413,7 @@ "type": "string" } }, - "required": [ - "name" - ], + "required": ["name"], "additionalProperties": false }, { @@ -493,10 +456,7 @@ "type": "boolean" } }, - "required": [ - "path", - "pathIsName" - ], + "required": ["path", "pathIsName"], "additionalProperties": false }, "result": { @@ -510,9 +470,7 @@ "type": "string" } }, - "required": [ - "name" - ], + "required": ["name"], "additionalProperties": false }, { @@ -549,9 +507,7 @@ "type": "string" } }, - "required": [ - "name" - ], + "required": ["name"], "additionalProperties": false }, { @@ -572,9 +528,7 @@ "$ref": "./lib/refs.json#/GfxSceneRef/properties/scenePath" } }, - "required": [ - "scenePath" - ], + "required": ["scenePath"], "additionalProperties": false }, "result": { @@ -588,9 +542,7 @@ "type": "string" } }, - "required": [ - "name" - ], + "required": ["name"], "additionalProperties": false }, { @@ -620,9 +572,7 @@ "type": "string" } }, - "required": [ - "name" - ], + "required": ["name"], "additionalProperties": false }, { @@ -643,9 +593,7 @@ "$ref": "./lib/refs.json#/MacroRef/properties/macroPath" } }, - "required": [ - "macroPath" - ], + "required": ["macroPath"], "additionalProperties": false } }, @@ -660,9 +608,7 @@ "$ref": "./lib/refs.json#/MacroRef/properties/macroPath" } }, - "required": [ - "macroPath" - ], + "required": ["macroPath"], "additionalProperties": false } }, @@ -680,10 +626,7 @@ "$ref": "./lib/refs.json#/SceneSnapshotRef/properties/snapshotPath" } }, - "required": [ - "scenePath", - "snapshotPath" - ], + "required": ["scenePath", "snapshotPath"], "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 index 7becfde2d7..46414f1168 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/$schemas/options.json +++ b/packages/timeline-state-resolver/src/integrations/kairos/$schemas/options.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "CasparCG Options", + "title": "KAIROS Options", "type": "object", "properties": { "host": { diff --git a/packages/timeline-state-resolver/src/integrations/kairos/commands.ts b/packages/timeline-state-resolver/src/integrations/kairos/commands.ts index b063d4c12d..f30dd851f3 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/commands.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/commands.ts @@ -229,7 +229,6 @@ export async function sendCommand( 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 if (command.playerType === 'image-store') await kairos.audioPlayerBegin(command.playerId) else assertNever(command.playerType) break } @@ -331,7 +330,7 @@ export async function sendCommand( break } case 'ram-rec-player': { - const values = command.values + const values = { ...command.values } if (values.clip) { // Handle loading ramrec/still into RAM if needed const clip = values.clip diff --git a/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts b/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts index a26192869c..de9283a8e9 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/diffState.ts @@ -5,7 +5,7 @@ import type { KairosCommandWithContext } from '.' // eslint-disable-next-line node/no-missing-import import { UpdateSceneLayerObject, UpdateSceneObject, UpdateAuxObject } from 'kairos-connection' -import { diffMediaPlayers, diffMediaImageStore as diffMediaImageStore } from './diffState/media-players' +import { diffMediaPlayers, diffMediaImageStore } from './diffState/media-players' import { diffObject, getAllKeysString } from './diffState/lib' export function diffKairosStates( diff --git a/packages/timeline-state-resolver/src/integrations/kairos/diffState/lib.ts b/packages/timeline-state-resolver/src/integrations/kairos/diffState/lib.ts index 920895767a..0d8e13e44d 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/diffState/lib.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/diffState/lib.ts @@ -1,7 +1,8 @@ import { isEqual } from 'underscore' /** - * Does a shallow comparision of two objects, returning an object with only the changed keys. + * 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 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 index 158c835a01..9fb6aca2ad 100644 --- a/packages/timeline-state-resolver/src/integrations/kairos/diffState/media-players.ts +++ b/packages/timeline-state-resolver/src/integrations/kairos/diffState/media-players.ts @@ -190,10 +190,10 @@ export function diffMediaImageStore( const oldImageStore = oldImageStores[playerId] const cpRef = newImageStore?.ref || oldImageStore?.ref - if (!cpRef) continue // No ClipPlayer to diff + if (!cpRef) continue // No ImageStore to diff if (!newImageStore && oldImageStore) { - // ClipPlayer obj was removed, stop it: + // ImageStore obj was removed, stop it: const clearPlayerOnStop = oldImageStore.state.content.imageStore.clearPlayerOnStop ?? @@ -216,7 +216,7 @@ export function diffMediaImageStore( // Do nothing, just leave it } } else if (newImageStore) { - /** The properties to update on the ClipPlayer */ + /** The properties to update on the ImageStore */ const updateCmd: KairosImageStoreCommand = { type: 'image-store', playerId: playerId,