diff --git a/src/backend/integrations/builtin/obs/communicator.ts b/src/backend/integrations/builtin/obs/communicator.ts index 520940995..e0a4cd8db 100644 --- a/src/backend/integrations/builtin/obs/communicator.ts +++ b/src/backend/integrations/builtin/obs/communicator.ts @@ -13,7 +13,9 @@ import { getImageSources, getMediaSources, getColorSources, - getSupportedImageFormats + getSupportedImageFormats, + getTransformableSceneItems, + OBSSceneItem } from "./obs-remote"; export function setupFrontendListeners( @@ -39,6 +41,14 @@ export function setupFrontendListeners( getSourcesWithFilters ); + frontendCommunicator.onAsync>( + "obs-get-transformable-scene-items", + (args: [sceneName: string]) => { + const [sceneName] = args; + return getTransformableSceneItems(sceneName); + } + ); + frontendCommunicator.onAsync>( "obs-get-audio-sources", getAudioSources diff --git a/src/backend/integrations/builtin/obs/effects/transform-obs-source-scale.ts b/src/backend/integrations/builtin/obs/effects/transform-obs-source-scale.ts new file mode 100644 index 000000000..048e96b6c --- /dev/null +++ b/src/backend/integrations/builtin/obs/effects/transform-obs-source-scale.ts @@ -0,0 +1,240 @@ +import { EffectType } from "../../../../../types/effects"; +import { OBSSceneItem, OBSSourceTransformKeys, transformSceneItem } from "../obs-remote"; + +export const TransformSourceScaleEffectType: EffectType<{ + sceneName?: string; + sceneItem?: OBSSceneItem; + duration: number; + easeIn: boolean; + easeOut: boolean; + isTransformingPosition: boolean; + isTransformingScale: boolean; + isTransformingRotation: boolean; + startTransform: Record; + endTransform: Record; +}> = { + definition: { + id: "firebot:obs-transform-source", + name: "Transform OBS Source", + description: "Transforms the position, scale, or rotation of an OBS source either instantly or animated over time", + icon: "fad fa-arrows", + categories: ["common"] + }, + optionsTemplate: ` + +
+ +
+ + {{$select.selected.name}} + +
+
+ + No Scenes found. + +
+
+ +
+ +
+ + {{$select.selected.name}} + +
+
+ + No transformable sources found. + +
+
+ No transformable sources found. {{ isObsConfigured ? "Is OBS running?" : "Have you configured the OBS integration?" }} +
+
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+
+ `, + optionsController: ($scope: any, backendCommunicator: any) => { + $scope.isObsConfigured = false; + + $scope.scenes = []; + $scope.sceneItems = []; + + $scope.selectScene = (sceneName: string) => { + $scope.effect.sceneItem = undefined; + $scope.getSources(sceneName); + }; + + $scope.selectSceneItem = (sceneItem: OBSSceneItem) => { + $scope.effect.sceneItem = sceneItem; + }; + + $scope.getScenes = () => { + $scope.isObsConfigured = backendCommunicator.fireEventSync("obs-is-configured"); + + backendCommunicator.fireEventAsync("obs-get-scene-list").then( + (scenes: string[] | undefined) => { + $scope.scenes = scenes?.map(scene => ({ name: scene, custom: false })) ?? []; + $scope.scenes.push($scope.customScene); + + if ($scope.effect.sceneName != null) { + $scope.getSources($scope.effect.sceneName); + } + } + ); + }; + $scope.getScenes(); + + $scope.getSources = (sceneName: string) => { + $scope.isObsConfigured = backendCommunicator.fireEventSync("obs-is-configured"); + + backendCommunicator.fireEventAsync("obs-get-transformable-scene-items", [sceneName]).then( + (sceneItems: OBSSceneItem[]) => { + $scope.sceneItems = sceneItems ?? []; + } + ); + }; + }, + optionsValidator: (effect) => { + if (effect.sceneName == null) { + return ["Please select a scene."]; + } + if (effect.sceneItem == null) { + return ["Please select a source."]; + } + if (effect.duration == null) { + return ["Please enter a duration."]; + } + return []; + }, + onTriggerEvent: async ({ effect }) => { + const parsedStart: Record = {}; + const parsedEnd: Record = {}; + const transformKeys: Array = []; + if (effect.isTransformingPosition) { + transformKeys.push("positionX", "positionY"); + } + if (effect.isTransformingScale) { + transformKeys.push("scaleX", "scaleY"); + } + if (effect.isTransformingRotation) { + transformKeys.push("rotation"); + } + + transformKeys.forEach((key) => { + if (effect.startTransform?.hasOwnProperty(key) && effect.startTransform[key].length) { + const value = Number(effect.startTransform[key]); + if (!isNaN(value)) { + parsedStart[key] = value; + } + } + if (effect.endTransform?.hasOwnProperty(key) && effect.endTransform[key].length) { + const value = Number(effect.endTransform[key]); + if (!isNaN(value)) { + parsedEnd[key] = value; + } + } + }); + + await transformSceneItem(effect.sceneName, effect.sceneItem.id, effect.duration, parsedStart, parsedEnd, effect.easeIn, effect.easeOut); + + return true; + } +}; diff --git a/src/backend/integrations/builtin/obs/obs-integration.ts b/src/backend/integrations/builtin/obs/obs-integration.ts index 1739b4ceb..2c686713d 100644 --- a/src/backend/integrations/builtin/obs/obs-integration.ts +++ b/src/backend/integrations/builtin/obs/obs-integration.ts @@ -23,6 +23,7 @@ import { CreateRecordChapter } from "./effects/create-recording-chapter"; import { ToggleSourceVisibilityEffectType } from "./effects/toggle-obs-source-visibility"; import { ToggleSourceFilterEffectType } from "./effects/toggle-obs-source-filter"; import { ToggleSourceMutedEffectType } from "./effects/toggle-obs-source-muted"; +import { TransformSourceScaleEffectType } from "./effects/transform-obs-source-scale"; import { StartStreamEffectType } from "./effects/start-stream"; import { StopStreamEffectType } from "./effects/stop-stream"; import { StartVirtualCamEffectType } from "./effects/start-virtual-cam"; @@ -142,6 +143,7 @@ class ObsIntegration effectManager.registerEffect(ToggleSourceVisibilityEffectType); effectManager.registerEffect(ToggleSourceFilterEffectType); effectManager.registerEffect(ToggleSourceMutedEffectType); + effectManager.registerEffect(TransformSourceScaleEffectType); effectManager.registerEffect(StartStreamEffectType); effectManager.registerEffect(StopStreamEffectType); effectManager.registerEffect(StartVirtualCamEffectType); diff --git a/src/backend/integrations/builtin/obs/obs-remote.ts b/src/backend/integrations/builtin/obs/obs-remote.ts index 96a4aca0c..1d2081a0b 100644 --- a/src/backend/integrations/builtin/obs/obs-remote.ts +++ b/src/backend/integrations/builtin/obs/obs-remote.ts @@ -648,6 +648,13 @@ export type OBSSourceScreenshotSettings = { imageCompressionQuality?: number; } +export type OBSSourceTransformKeys = + | "positionX" + | "positionY" + | "scaleX" + | "scaleY" + | "rotation"; + export async function getAllSources(): Promise | null> { if (!connected) { return null; @@ -785,6 +792,116 @@ export async function getAudioSources(): Promise> { return audioSupportedSources; } +export async function getTransformableSceneItems(sceneName: string): Promise> { + const sceneItems = await getAllSceneItemsInScene(sceneName) ?? []; + const sources = (await getAllSources()) ?? []; + + return sceneItems.filter(item => sources.some(source => source.name === item.name && !source.typeId.startsWith("wasapi"))); +} + +const transformWebsocketRequest = (sceneName: string, sceneItemId: number, sceneItemTransform: Record) => ({ + requestType: "SetSceneItemTransform", + requestData: { + sceneName, + sceneItemId, + sceneItemTransform + } +}); + +// For future Oshi or someone who thinks up a clean solution to this, this method should ultimately allow +// for any two OBS websocket payloads of the same type to be passed in, and will create a lerped response between +// them both, not be opinionated to just work with Transform. +function getLerpedCallsArray( + sceneName: string, + sceneItemId: number, + transformStart: Record, + transformEnd: Record, + duration: number, + easeIn = false, + easeOut = false +) { + if (!duration) { + return [ + transformWebsocketRequest( + sceneName, + sceneItemId, + transformEnd && Object.keys(transformEnd).length + ? transformEnd + : transformStart + ) + ]; + } + + const calls = []; + const interval = 1 / 60; + + calls.push(transformWebsocketRequest(sceneName, sceneItemId, transformStart)); + if (!transformEnd || !Object.keys(transformEnd).length) { + return calls; + } + + let time = 0; + do { + const delay = Math.min(interval * 1000, duration - time); + const frame: Record = {}; + + calls.push({ + requestType: "Sleep", + requestData: { sleepMillis: delay } + }); + + time += delay; + Object.keys(transformEnd).forEach((key) => { + if (transformStart[key] === transformEnd[key]) { + return; + } + let ratio = time / duration; + if (easeIn && easeOut) { + ratio = ratio < 0.5 ? 2 * ratio * ratio : -1 + (4 - 2 * ratio) * ratio; + } else if (easeIn) { + ratio = ratio * ratio; + } else if (easeOut) { + ratio = ratio * (2 - ratio); + } + frame[key] = transformStart[key] + (transformEnd[key] - transformStart[key]) * ratio; + }); + + calls.push(transformWebsocketRequest(sceneName, sceneItemId, frame)); + } while (time < duration); + return calls; +} + +export async function transformSceneItem( + sceneName: string, + sceneItemId: number, + duration: number, + transformStart: Record, + transformEnd: Record, + easeIn: boolean, + easeOut: boolean +) { + try { + const currentTransform = (await obs.call("GetSceneItemTransform", { + sceneName, + sceneItemId + })).sceneItemTransform; + + Object.keys(transformEnd).forEach((key) => { + if (!transformStart.hasOwnProperty(key)) { + transformStart[key] = Number(currentTransform[key]); + } + if (transformEnd[key] === transformStart[key]) { + delete transformEnd[key]; + } + }); + + const calls = getLerpedCallsArray(sceneName, sceneItemId, transformStart, transformEnd, duration, easeIn, easeOut); + await obs.callBatch(calls); + } catch (error) { + logger.error("Failed to transform scene item", error); + } +} + export async function toggleSourceMuted(sourceName: string) { try { await obs.call("ToggleInputMute", {