diff --git a/app/app-services.ts b/app/app-services.ts index bd63de322c0d..0b3ab6060f87 100644 --- a/app/app-services.ts +++ b/app/app-services.ts @@ -94,6 +94,7 @@ export { RecentEventsService } from 'services/recent-events'; export { MagicLinkService } from 'services/magic-link'; export { GrowService } from 'services/grow/grow'; export { GuestCamService } from 'services/guest-cam'; +export { GreenService } from 'services/green'; // WIDGETS export { WidgetSource, WidgetsService } from './services/widgets'; @@ -186,6 +187,7 @@ import { SideNavService } from './services/side-nav'; import { VideoSettingsService } from 'services/settings-v2/video'; import { SettingsManagerService } from 'services/settings-manager'; import { MarkersService } from 'services/markers'; +import { GreenService } from 'services/green'; export const AppServices = { AppService, @@ -256,4 +258,5 @@ export const AppServices = { VideoSettingsService, SettingsManagerService, MarkersService, + GreenService, }; diff --git a/app/components-react/shared/Display.tsx b/app/components-react/shared/Display.tsx index b87b19ccfd0e..234f7fe196d1 100644 --- a/app/components-react/shared/Display.tsx +++ b/app/components-react/shared/Display.tsx @@ -2,8 +2,8 @@ import React, { useEffect, useRef } from 'react'; import { useVuex } from '../hooks'; import { Services } from '../service-provider'; import { Display as OBSDisplay } from '../../services/video'; +import { TDisplayType } from 'services/settings-v2/video'; import uuid from 'uuid/v4'; - interface DisplayProps { sourceId?: string; paddingSize?: number; @@ -12,10 +12,11 @@ interface DisplayProps { onOutputResize?: (region: IRectangle) => void; clickHandler?: (event: React.MouseEvent) => void; style?: React.CSSProperties; + type?: TDisplayType; } export default function Display(props: DisplayProps) { - const { VideoService, CustomizationService } = Services; + const { CustomizationService, VideoSettingsService } = Services; const p = { paddingSize: 0, @@ -25,13 +26,14 @@ export default function Display(props: DisplayProps) { ...props, }; - const v = useVuex( - () => ({ + const v = useVuex(() => { + const videoSettings = VideoSettingsService.contexts[p.type ?? 'horizontal']?.video; + + return { paddingColor: CustomizationService.views.displayBackground, - baseResolution: VideoService.baseResolution, - }), - false, - ); + baseResolution: `${videoSettings?.baseWidth}x${videoSettings?.baseHeight}`, + }; + }, false); const obsDisplay = useRef(null); const displayEl = useRef(null); @@ -55,6 +57,7 @@ export default function Display(props: DisplayProps) { paddingSize: p.paddingSize, paddingColor: v.paddingColor, renderingMode: p.renderingMode, + type: p.type, }); obsDisplay.current.setShoulddrawUI(p.drawUI); obsDisplay.current.onOutputResize(region => p.onOutputResize(region)); @@ -79,7 +82,12 @@ export default function Display(props: DisplayProps) {
); diff --git a/app/components-react/shared/inputs/FormFactory.tsx b/app/components-react/shared/inputs/FormFactory.tsx index 27a59e7272c8..1935c0e963da 100644 --- a/app/components-react/shared/inputs/FormFactory.tsx +++ b/app/components-react/shared/inputs/FormFactory.tsx @@ -33,6 +33,7 @@ export default function FormFactory(p: { onChange: (key: string) => (value: TInputValue) => void; values: Dictionary; formOptions?: FormProps; + name?: string; }) { const form = useForm(); @@ -41,6 +42,7 @@ export default function FormFactory(p: { return (
debounce(form.validateFields, 500)()} > diff --git a/app/components-react/windows/settings/Video.tsx b/app/components-react/windows/settings/Video.tsx index aeb4619c676f..e3a17cf927ff 100644 --- a/app/components-react/windows/settings/Video.tsx +++ b/app/components-react/windows/settings/Video.tsx @@ -183,7 +183,7 @@ class VideoSettingsModule { } get outputResOptions() { - const baseRes = `${this.service.state.videoContext.baseWidth}x${this.service.state.videoContext.baseHeight}`; + const baseRes = `${this.service.state.horizontal.baseWidth}x${this.service.state.horizontal.baseHeight}`; if (!OUTPUT_RES_OPTIONS.find(opt => opt.value === baseRes)) { return [{ label: baseRes, value: baseRes }] .concat(OUTPUT_RES_OPTIONS) @@ -259,7 +259,6 @@ class VideoSettingsModule { selectResolution(key: string, value: string) { if (value === 'custom') { this.setCustomResolution(key, true); - this.setResolution(key, ''); } else { this.setCustomResolution(key, false); this.setResolution(key, value); @@ -321,6 +320,7 @@ export function VideoSettings() { metadata={metadata} onChange={onChange} formOptions={{ layout: 'vertical' }} + name="video-settings" />
); diff --git a/app/components/windows/settings/Settings.vue.ts b/app/components/windows/settings/Settings.vue.ts index cfd86aee4462..dd4338530193 100644 --- a/app/components/windows/settings/Settings.vue.ts +++ b/app/components/windows/settings/Settings.vue.ts @@ -181,7 +181,9 @@ export default class Settings extends Vue { } get categoryNames() { - return this.settingsService.getCategories(); + return this.settingsService + .getCategories() + .filter(category => !category.toLowerCase().startsWith('stream') || category === 'Stream'); } save(settingsData: ISettingsSubCategory[]) { diff --git a/app/services/app/app.ts b/app/services/app/app.ts index ff1b5ea9cac1..eeb3230bf8b6 100644 --- a/app/services/app/app.ts +++ b/app/services/app/app.ts @@ -41,6 +41,7 @@ import { MetricsService } from '../metrics'; import { SettingsService } from '../settings'; import { OS, getOS } from 'util/operating-systems'; import * as remote from '@electron/remote'; +import { VideoSettingsService } from 'services/settings-v2/video'; interface IAppState { loading: boolean; @@ -90,6 +91,7 @@ export class AppService extends StatefulService { @Inject() private metricsService: MetricsService; @Inject() private settingsService: SettingsService; @Inject() private usageStatisticsService: UsageStatisticsService; + @Inject() private videoSettingsService: VideoSettingsService; static initialState: IAppState = { loading: true, @@ -191,6 +193,7 @@ export class AppService extends StatefulService { await this.sceneCollectionsService.deinitialize(); this.performanceService.stop(); this.transitionsService.shutdown(); + this.videoSettingsService.shutdown(); await this.gameOverlayService.destroy(); await this.fileManagerService.flushAll(); obs.NodeObs.RemoveSourceCallback(); diff --git a/app/services/green/green-data.ts b/app/services/green/green-data.ts new file mode 100644 index 000000000000..37b29d9c29e8 --- /dev/null +++ b/app/services/green/green-data.ts @@ -0,0 +1,59 @@ +import { $t } from 'services/i18n'; +import { TPlatform } from 'services/platforms'; + +export enum EOutputDisplayType { + Horizontal = 'horizontal', + Green = 'green', +} + +export type TGreenDisplayType = EOutputDisplayType.Horizontal | EOutputDisplayType.Green; + +export interface IGreenPlatformSetting { + platform: TPlatform; + display: EOutputDisplayType; +} + +export type TGreenPlatformSettings = { + [Platform in TPlatform]: IGreenPlatformSetting; +}; + +export type TDisplayPlatforms = { + [Display in EOutputDisplayType]: TPlatform[]; +}; + +export const GreenPlatformSettings: TGreenPlatformSettings = { + ['twitch']: { + platform: 'twitch', + display: EOutputDisplayType.Horizontal, + }, + ['facebook']: { + platform: 'facebook', + display: EOutputDisplayType.Horizontal, + }, + ['youtube']: { + platform: 'youtube', + display: EOutputDisplayType.Horizontal, + }, + ['trovo']: { + platform: 'trovo', + display: EOutputDisplayType.Horizontal, + }, + ['tiktok']: { + platform: 'tiktok', + display: EOutputDisplayType.Green, + }, +}; + +export const platformLabels = (platform: TPlatform | string) => + ({ + ['twitch']: $t('Twitch'), + ['facebook']: $t('Facebook'), + ['youtube']: $t('YouTube'), + ['trovo']: $t('Trovo'), + }[platform]); + +export const displayLabels = (display: EOutputDisplayType | string) => + ({ + [EOutputDisplayType.Horizontal]: $t('Horizontal'), + [EOutputDisplayType.Green]: $t('Green'), + }[display]); diff --git a/app/services/green/green.ts b/app/services/green/green.ts new file mode 100644 index 000000000000..f420c7e2605c --- /dev/null +++ b/app/services/green/green.ts @@ -0,0 +1,512 @@ +import { PersistentStatefulService, InitAfter, Inject, ViewHandler, mutation } from 'services/core'; +import { + TDisplayPlatforms, + TGreenPlatformSettings, + GreenPlatformSettings, + TGreenDisplayType, + IGreenPlatformSetting, +} from './green-data'; +import { ScenesService, SceneItem, IPartialSettings } from 'services/scenes'; +import { + TDisplayType, + VideoSettingsService, + IVideoSetting, + greenDisplayData, +} from 'services/settings-v2'; +import { StreamingService } from 'services/streaming'; +import { SceneCollectionsService } from 'services/scene-collections'; +import { TPlatform } from 'services/platforms'; +import { ReorderNodesCommand, EPlaceType } from 'services/editor-commands/commands/reorder-nodes'; +import { Subject } from 'rxjs'; +import { TOutputOrientation } from 'services/restream'; +import { IVideoInfo } from 'obs-studio-node'; + +interface IDisplayVideoSettings { + defaultDisplay: TDisplayType; + green: IVideoInfo; + activeDisplays: { + horizontal: boolean; + green: boolean; + }; +} +interface IGreenServiceState { + displays: TDisplayType[]; + platformSettings: TGreenPlatformSettings; + greenMode: boolean; + nodeMaps: { [display in TDisplayType as string]: Dictionary }; + sceneNodeMaps: { [sceneId: string]: Dictionary }; + videoSettings: IDisplayVideoSettings; +} + +class GreenViews extends ViewHandler { + @Inject() private scenesService: ScenesService; + @Inject() private videoSettingsService: VideoSettingsService; + @Inject() private streamingService: StreamingService; + + get greenMode(): boolean { + return this.activeDisplays.horizontal && this.activeDisplays.green; + } + + get platformSettings() { + return this.state.platformSettings; + } + + get platformSettingsList(): IGreenPlatformSetting[] { + return Object.values(this.state.platformSettings); + } + + get hasGreenScenes() { + if (!this.state.nodeMaps) return false; + for (const display in this.state.nodeMaps) { + if (!this.state.nodeMaps.hasOwnProperty(display)) { + return false; + } + } + return true; + } + + get showGreenDisplays() { + return this.greenMode && this.hasGreenScenes; + } + + get hasGreenNodes() { + return this.state.sceneNodeMaps.hasOwnProperty(this.scenesService.views.activeSceneId); + } + + get greenNodeIds(): string[] { + const activeSceneId = this.scenesService.views.activeSceneId; + + if (!this.hasGreenNodes) return; + + return Object.values(this.state.sceneNodeMaps[activeSceneId]); + } + + get displays() { + return this.state.displays; + } + + get contextsToStream() { + return Object.entries(this.activeDisplayPlatforms).reduce( + (contextNames: TDisplayType[], [key, value]: [TDisplayType, TPlatform[]]) => { + if (value.length > 0) { + contextNames.push(key as TDisplayType); + } + return contextNames; + }, + [], + ); + } + + get activeDisplayPlatforms() { + const enabledPlatforms = this.streamingService.views.enabledPlatforms; + return Object.entries(this.state.platformSettings).reduce( + (displayPlatforms: TDisplayPlatforms, [key, val]: [string, IGreenPlatformSetting]) => { + if (val && enabledPlatforms.includes(val.platform)) { + displayPlatforms[val.display].push(val.platform); + } + return displayPlatforms; + }, + { horizontal: [], green: [] }, + ); + } + + get nodeMaps() { + return this.state.nodeMaps; + } + + get sceneNodeMaps() { + return this.state.sceneNodeMaps; + } + + get videoSettings(): IDisplayVideoSettings { + return this.state.videoSettings; + } + + get activeDisplays() { + return this.state.videoSettings.activeDisplays; + } + + get defaultDisplay() { + const active = Object.entries(this.state.videoSettings.activeDisplays).map(([key, value]) => { + if (value === true) { + return { key }; + } + }); + return active.length > 1 ? null : active[0]; + } + + getNodeIds(displays: TDisplayType[]) { + return displays.reduce((ids: string[], display: TDisplayType) => { + const nodeMap = this.state.nodeMaps[display]; + const aggregatedIds = Object.values(nodeMap).concat(ids); + + return aggregatedIds; + }, []); + } + + getPlatformDisplay(platform: TPlatform) { + return this.state.platformSettings[platform].display; + } + + getPlatformContext(platform: TPlatform) { + const display = this.getPlatformDisplay(platform); + return this.videoSettingsService.contexts[display]; + } + + getDisplayNodeMap(display: TDisplayType) { + return this.state.nodeMaps[display]; + } + + getDisplayNodeId(defaultNodeId: string) { + return this.state.sceneNodeMaps[this.scenesService.views.activeSceneId][defaultNodeId]; + } + + getDisplayNodeVisibility(defaultNodeId: string, display?: TDisplayType) { + if (display === 'horizontal') { + return this.scenesService.views.getNodeVisibility(defaultNodeId); + } else { + const nodeId = this.getDisplayNodeId(defaultNodeId); + return this.scenesService.views.getNodeVisibility(nodeId); + } + } + + getGreenNodeIds(sceneItemId: string) { + return [ + this.state.nodeMaps['horizontal'][sceneItemId], + this.state.nodeMaps['green'][sceneItemId], + ]; + } + + getPlatformContextName(platform: TPlatform): TOutputOrientation { + return this.getPlatformDisplay(platform) === 'horizontal' ? 'landscape' : 'portrait'; + } +} + +export class GreenService extends PersistentStatefulService { + @Inject() private scenesService: ScenesService; + @Inject() private sceneCollectionsService: SceneCollectionsService; + @Inject() private videoSettingsService: VideoSettingsService; + + static defaultState: IGreenServiceState = { + displays: ['horizontal', 'green'], + platformSettings: GreenPlatformSettings, + greenMode: false, + nodeMaps: null, + sceneNodeMaps: {}, + videoSettings: { + defaultDisplay: 'horizontal', + green: greenDisplayData, // get settings for horizontal display from obs directly + activeDisplays: { + horizontal: true, + green: false, + }, + }, + }; + + sceneItemsConfirmed = new Subject(); + sceneItemsDestroyed = new Subject(); + + get views() { + return new GreenViews(this.state); + } + + init() { + super.init(); + + // this.sceneCollectionsService.collectionInitialized.subscribe(() => { + // if ( + // this.scenesService.views.getSceneItemsBySceneId(this.scenesService.views.activeSceneId) + // .length > 0 + // ) { + // this.confirmOrCreateGreenNodes(); + // } + // }); + + // this.sceneCollectionsService.collectionSwitched.subscribe(() => { + // if ( + // this.scenesService.views.getSceneItemsBySceneId(this.scenesService.views.activeSceneId) + // .length > 0 + // ) { + // this.confirmOrCreateGreenNodes(); + // } + // }); + + // this.scenesService.sceneAdded.subscribe((scene: IScene) => { + // if (this.videoSettingsService.contexts.green) { + // this.assignSceneNodes(scene.id); + // } + // }); + + // this.scenesService.sceneSwitched.subscribe((scene: IScene) => { + // if (this.scenesService.views.getSceneItemsBySceneId(scene.id).length > 0) { + // this.confirmOrCreateGreenNodes(scene.id); + // } + // }); + } + + /** + * Create or confirm nodes for green output when toggling green display + */ + + confirmOrCreateGreenNodes(sceneId?: string) { + if ( + !this.state.sceneNodeMaps.hasOwnProperty(sceneId ?? this.scenesService.views.activeSceneId) + ) { + try { + this.mapSceneNodes(this.views.displays); + } catch (error: unknown) { + console.error('Error toggling Green mode: ', error); + } + } else { + try { + this.assignSceneNodes(); + } catch (error: unknown) { + console.error('Error toggling Green mode: ', error); + } + } + + if (!this.videoSettingsService.contexts.green) { + this.videoSettingsService.establishVideoContext('green'); + } + + this.sceneItemsConfirmed.next(); + } + + assignSceneNodes(sceneId?: string) { + const activeSceneId = this.scenesService.views.activeSceneId; + const sceneItems = this.scenesService.views.getSceneItemsBySceneId(sceneId ?? activeSceneId); + const greenNodeIds = this.views.greenNodeIds; + + if (!this.videoSettingsService.contexts.green) { + this.videoSettingsService.establishVideoContext('green'); + } + + sceneItems.forEach(sceneItem => { + const context = greenNodeIds.includes(sceneItem.id) ? 'green' : 'horizontal'; + this.assignNodeContext(sceneItem, context); + }); + } + + mapSceneNodes(displays: TDisplayType[], sceneId?: string) { + const sceneToMapId = sceneId ?? this.scenesService.views.activeSceneId; + return displays.reduce((created: boolean, display: TDisplayType, index) => { + const isFirstDisplay = index === 0; + if (!this.videoSettingsService.contexts[display]) { + this.videoSettingsService.establishVideoContext(display); + } + const nodesCreated = this.createOutputNodes(sceneToMapId, display, isFirstDisplay); + if (!nodesCreated) { + created = false; + } + return created; + }, true); + } + + createOutputNodes(sceneId: string, display: TDisplayType, isFirstDisplay: boolean) { + const sceneNodes = this.scenesService.views.getSceneItemsBySceneId(sceneId); + if (!sceneNodes) return false; + return sceneNodes.reduce((created: boolean, sceneItem: SceneItem) => { + const nodeCreatedId = this.createOrAssignOutputNode( + sceneItem, + display, + isFirstDisplay, + sceneId, + ); + if (!nodeCreatedId) { + created = false; + } + return created; + }, true); + } + + createOrAssignOutputNode( + sceneItem: SceneItem, + display: TDisplayType, + isFirstDisplay: boolean, + sceneId?: string, + ) { + if (isFirstDisplay) { + // if it's the first display, just assign the scene item's output to a context + this.assignNodeContext(sceneItem, display); + + // @@@ TODO: Remove + // create a node map entry even though the key and value are the same + this.SET_NODE_MAP_ITEM(display, sceneItem.id, sceneItem.id, sceneId); + return sceneItem.id; + } else { + // if it's not the first display, copy the scene item + const scene = this.scenesService.views.getScene(sceneId); + const copiedSceneItem = scene.addSource(sceneItem.sourceId); + const context = this.videoSettingsService.contexts[display]; + + if (!copiedSceneItem || !context) return null; + + const settings: IPartialSettings = { ...sceneItem.getSettings(), output: context }; + copiedSceneItem.setSettings(settings); + + const reorderNodesSubcommand = new ReorderNodesCommand( + scene.getSelection(copiedSceneItem.id), + sceneItem.id, + EPlaceType.Before, + ); + reorderNodesSubcommand.execute(); + + this.SET_NODE_MAP_ITEM(display, sceneItem.id, copiedSceneItem.id, sceneId); + return sceneItem.id; + } + } + + assignNodeContext(sceneItem: SceneItem, display: TDisplayType) { + const context = this.videoSettingsService.contexts[display]; + if (!context) return null; + + sceneItem.setSettings({ output: context }); + return sceneItem.id; + } + + /** + * Helper functions to manage displays + */ + + toggleDisplay(status: boolean, display?: TDisplayType) { + if ( + this.state.videoSettings.activeDisplays.horizontal && + this.state.videoSettings.activeDisplays.green + ) { + this.setDisplayActive(status, display); + } else if (display === 'horizontal' && status === false) { + this.sceneItemsConfirmed.subscribe(() => { + this.setDisplayActive(status, display); + }); + this.confirmOrCreateGreenNodes(); + } else if (display === 'green' && status === true) { + this.sceneItemsConfirmed.subscribe(() => { + this.setDisplayActive(status, display); + }); + this.confirmOrCreateGreenNodes(); + } else { + this.setDisplayActive(status, display); + } + } + + setVideoSetting(setting: Partial, display?: TDisplayType) { + this.SET_VIDEO_SETTING(setting, display); + } + + private setDisplayActive(status: boolean, display: TDisplayType) { + this.SET_DISPLAY_ACTIVE(status, display); + } + + /** + * Helper functions for adding and removing scene items in Green mode + */ + + removeGreenNodes(nodeId: string) { + this.REMOVE_DUAL_OUTPUT_NODES(nodeId); + } + + restoreNodesToMap(sceneItemId: string, greenSceneItemId: string) { + this.SET_NODE_MAP_ITEM('horizontal', sceneItemId, sceneItemId); + this.SET_NODE_MAP_ITEM('green', sceneItemId, greenSceneItemId); + } + + /** + * Settings for platforms to displays + */ + + updatePlatformSetting(platform: TPlatform | string, display: TGreenDisplayType) { + this.UPDATE_PLATFORM_SETTING(platform, display); + } + + @mutation() + private UPDATE_PLATFORM_SETTING(platform: TPlatform | string, display: TGreenDisplayType) { + this.state.platformSettings[platform] = { + ...this.state.platformSettings[platform], + display, + }; + } + + @mutation() + private TOGGLE_DUAL_OUTPUT_MODE(status: boolean) { + this.state.greenMode = status; + } + + @mutation() + private SET_EMPTY_NODE_MAP(display: TDisplayType) { + if (!this.state.nodeMaps) { + this.state.nodeMaps = {}; + } + this.state.nodeMaps[display] = {}; + } + + @mutation() + private SET_NODE_MAP_ITEM( + display: TDisplayType, + originalSceneNodeId: string, + copiedSceneNodeId: string, + sceneId?: string, + ) { + // @@@ TODO Remove + if (!this.state.nodeMaps) { + this.state.nodeMaps = {}; + } + this.state.nodeMaps[display] = { + ...this.state.nodeMaps[display], + [originalSceneNodeId]: copiedSceneNodeId, + }; + + if (display === 'green') { + this.state.sceneNodeMaps[sceneId] = { + ...this.state.sceneNodeMaps[sceneId], + [originalSceneNodeId]: copiedSceneNodeId, + }; + } + } + + @mutation() + private REMOVE_DUAL_OUTPUT_NODES(nodeId: string) { + // remove nodes from scene + + let newMap = {}; + for (const display in this.state.nodeMaps) { + newMap = { + ...newMap, + [display]: Object.entries(this.state.nodeMaps[display]).filter( + ([key, val]) => key !== nodeId, + ), + }; + } + this.state.nodeMaps = newMap; + } + + @mutation() + private SET_DISPLAY_ACTIVE(status: boolean, display: TDisplayType = 'horizontal') { + const otherDisplay = display === 'horizontal' ? 'green' : 'horizontal'; + if ( + status === false && + this.state.videoSettings.activeDisplays[display] && + !this.state.videoSettings.activeDisplays[otherDisplay] + ) { + this.state.videoSettings.activeDisplays = { + ...this.state.videoSettings.activeDisplays, + [display]: status, + [otherDisplay]: !status, + }; + } else { + this.state.videoSettings.activeDisplays = { + ...this.state.videoSettings.activeDisplays, + [display]: status, + }; + } + + this.state.videoSettings.defaultDisplay = display; + } + + @mutation() + private SET_VIDEO_SETTING(setting: Partial, display: TDisplayType = 'green') { + this.state.videoSettings.activeDisplays = { + ...this.state.videoSettings.activeDisplays, + [display]: setting, + }; + } +} diff --git a/app/services/green/index.ts b/app/services/green/index.ts new file mode 100644 index 000000000000..ad1d41e656c6 --- /dev/null +++ b/app/services/green/index.ts @@ -0,0 +1,2 @@ +export * from './green'; +export * from './green-data'; diff --git a/app/services/performance.ts b/app/services/performance.ts index e2509da6c8f9..d9ac799a3866 100644 --- a/app/services/performance.ts +++ b/app/services/performance.ts @@ -16,6 +16,7 @@ import { $t } from 'services/i18n'; import { StreamingService, EStreamingState } from 'services/streaming'; import { UsageStatisticsService } from './usage-statistics'; import { ViewHandler } from './core'; +import { VideoSettingsService } from './settings-v2/video'; interface IPerformanceState { CPU: number; @@ -110,6 +111,7 @@ export class PerformanceService extends StatefulService { @Inject() private troubleshooterService: TroubleshooterService; @Inject() private streamingService: StreamingService; @Inject() private usageStatisticsService: UsageStatisticsService; + @Inject() private videoSettingsService: VideoSettingsService; static initialState: IPerformanceState = { CPU: 0, @@ -148,7 +150,7 @@ export class PerformanceService extends StatefulService { } init() { - this.streamingService.streamingStatusChange.subscribe(state => { + this.streamingService.streamingStatusChange.subscribe((state: EStreamingState) => { if (state === EStreamingState.Live) this.startStreamQualityMonitoring(); if (state === EStreamingState.Ending) this.stopStreamQualityMonitoring(); }); @@ -196,10 +198,10 @@ export class PerformanceService extends StatefulService { * Capture some analytics for the entire duration of a stream */ startStreamQualityMonitoring() { - this.streamStartSkippedFrames = obs.VideoFactory.skippedFrames; + this.streamStartSkippedFrames = this.videoSettingsService.contexts.horizontal.skippedFrames; this.streamStartLaggedFrames = obs.Global.laggedFrames; this.streamStartRenderedFrames = obs.Global.totalFrames; - this.streamStartEncodedFrames = obs.VideoFactory.encodedFrames; + this.streamStartEncodedFrames = this.videoSettingsService.contexts.horizontal.encodedFrames; this.streamStartTime = new Date(); } @@ -209,8 +211,10 @@ export class PerformanceService extends StatefulService { (obs.Global.totalFrames - this.streamStartRenderedFrames)) * 100; const streamSkipped = - ((obs.VideoFactory.skippedFrames - this.streamStartSkippedFrames) / - (obs.VideoFactory.encodedFrames - this.streamStartEncodedFrames)) * + ((this.videoSettingsService.contexts.horizontal.skippedFrames - + this.streamStartSkippedFrames) / + (this.videoSettingsService.contexts.horizontal.encodedFrames - + this.streamStartEncodedFrames)) * 100; const streamDropped = this.state.percentageDroppedFrames; const streamDuration = new Date().getTime() - this.streamStartTime.getTime(); @@ -231,8 +235,8 @@ export class PerformanceService extends StatefulService { const currentStats: IMonitorState = { framesLagged: obs.Global.laggedFrames, framesRendered: obs.Global.totalFrames, - framesSkipped: obs.VideoFactory.skippedFrames, - framesEncoded: obs.VideoFactory.encodedFrames, + framesSkipped: this.videoSettingsService.contexts.horizontal.skippedFrames, + framesEncoded: this.videoSettingsService.contexts.horizontal.encodedFrames, }; const nextStats = this.nextStats(currentStats); diff --git a/app/services/platforms/base-platform.ts b/app/services/platforms/base-platform.ts index cbbb52f0703f..e97739280d6e 100644 --- a/app/services/platforms/base-platform.ts +++ b/app/services/platforms/base-platform.ts @@ -13,6 +13,8 @@ import { HostsService } from 'services/hosts'; import { IFacebookStartStreamOptions } from './facebook'; import { StreamSettingsService } from '../settings/streaming'; import * as remote from '@electron/remote'; +import { GreenService } from 'services/green'; +import { VideoSettingsService } from 'services/settings-v2'; const VIEWER_COUNT_UPDATE_INTERVAL = 60 * 1000; @@ -32,6 +34,9 @@ export abstract class BasePlatformService extends Stat @Inject() protected userService: UserService; @Inject() protected hostsService: HostsService; @Inject() protected streamSettingsService: StreamSettingsService; + @Inject() protected greenService: GreenService; + @Inject() protected videoSettingsService: VideoSettingsService; + abstract readonly platform: TPlatform; abstract capabilities: Set; @@ -114,6 +119,16 @@ export abstract class BasePlatformService extends Stat return Promise.resolve({}); } + setPlatformContext(platform: TPlatform) { + if (this.greenService.views.greenMode) { + const mode = this.greenService.views.getPlatformContextName(platform); + + this.UPDATE_STREAM_SETTINGS({ + mode, + }); + } + } + @mutation() protected SET_VIEWERS_COUNT(viewers: number) { this.state.viewersCount = viewers; diff --git a/app/services/platforms/facebook.ts b/app/services/platforms/facebook.ts index c545e4b693b4..d6ed7bfac4f8 100644 --- a/app/services/platforms/facebook.ts +++ b/app/services/platforms/facebook.ts @@ -13,7 +13,8 @@ import { throwStreamError } from 'services/streaming/stream-error'; import { BasePlatformService } from './base-platform'; import { WindowsService } from '../windows'; import { assertIsDefined, getDefined } from '../../util/properties-type-guards'; - +import { TDisplayType } from 'services/settings-v2'; +import { TOutputOrientation } from 'services/restream'; interface IFacebookPage { access_token: string; name: string; @@ -91,6 +92,7 @@ export interface IFacebookStartStreamOptions { description?: string; liveVideoId?: string; privacy?: { value: TFacebookStreamPrivacy }; + mode?: TOutputOrientation; event_params: { start_time?: number; cover?: string; status?: TFacebookStatus }; } @@ -118,6 +120,7 @@ const initialState: IFacebookServiceState = { title: '', description: '', game: '', + mode: undefined, event_params: {}, privacy: { value: 'EVERYONE' }, }, @@ -233,7 +236,7 @@ export class FacebookService return this.state.streamDashboardUrl; } - async beforeGoLive(options: IGoLiveSettings) { + async beforeGoLive(options: IGoLiveSettings, context?: TDisplayType) { const fbOptions = getDefined(options.platforms.facebook); let liveVideo: IFacebookLiveVideo; @@ -253,12 +256,15 @@ export class FacebookService const streamUrl = liveVideo.stream_url; const streamKey = streamUrl.slice(streamUrl.lastIndexOf('/') + 1); if (!this.streamingService.views.isMultiplatformMode) { - this.streamSettingsService.setSettings({ - key: streamKey, - platform: 'facebook', - streamType: 'rtmp_common', - server: 'rtmps://rtmp-api.facebook.com:443/rtmp/', - }); + this.streamSettingsService.setSettings( + { + key: streamKey, + platform: 'facebook', + streamType: 'rtmp_common', + server: 'rtmps://rtmp-api.facebook.com:443/rtmp/', + }, + context, + ); } this.SET_STREAM_KEY(streamKey); this.SET_STREAM_PAGE_URL(`https://facebook.com/${liveVideo.permalink_url}`); @@ -271,6 +277,8 @@ export class FacebookService assertIsDefined(fbOptions.pageId); await this.postPage(fbOptions.pageId); } + + this.setPlatformContext('facebook'); } /** diff --git a/app/services/platforms/index.ts b/app/services/platforms/index.ts index 882d9dcec45c..1184e23645b0 100644 --- a/app/services/platforms/index.ts +++ b/app/services/platforms/index.ts @@ -6,6 +6,7 @@ import { TTwitchOAuthScope } from './twitch/index'; import { IGoLiveSettings } from 'services/streaming'; import { WidgetType } from '../widgets'; import { ITrovoStartStreamOptions, TrovoService } from './trovo'; +import { TDisplayType } from 'services/settings-v2/video'; export type Tag = string; export interface IGame { @@ -169,7 +170,7 @@ export interface IPlatformService { /** * Sets up the stream key and live broadcast info required to go live. */ - beforeGoLive: (options?: IGoLiveSettings) => Promise; + beforeGoLive: (options?: IGoLiveSettings, context?: TDisplayType) => Promise; afterGoLive: () => Promise; @@ -186,6 +187,8 @@ export interface IPlatformService { useToken?: boolean | string, ) => Dictionary; + setPlatformContext?: (platform: TPlatform) => void; + liveDockEnabled: boolean; readonly apiBase: string; @@ -217,7 +220,7 @@ export interface IUserInfo { username?: string; } -export type TPlatform = 'twitch' | 'youtube' | 'facebook' | 'tiktok' | 'trovo'; +export type TPlatform = 'twitch' | 'facebook' | 'youtube' | 'tiktok' | 'trovo'; export function getPlatformService(platform: TPlatform): IPlatformService { return { diff --git a/app/services/platforms/tiktok.ts b/app/services/platforms/tiktok.ts index 2cf4ae283734..0dde18fd4362 100644 --- a/app/services/platforms/tiktok.ts +++ b/app/services/platforms/tiktok.ts @@ -4,10 +4,13 @@ import { IPlatformCapabilityResolutionPreset, IPlatformState, TPlatformCapabilit import { IGoLiveSettings } from '../streaming'; import { WidgetType } from '../widgets'; import { getDefined } from '../../util/properties-type-guards'; +import { TDisplayType } from 'services/settings-v2'; +import { TOutputOrientation } from 'services/restream'; export interface ITiktokStartStreamOptions { serverUrl: string; streamKey: string; + mode?: TOutputOrientation; } interface ITiktokServiceState extends IPlatformState { @@ -49,16 +52,20 @@ export class TiktokService return this.userService.views.state.auth?.platforms?.facebook?.token; } - async beforeGoLive(goLiveSettings: IGoLiveSettings) { + async beforeGoLive(goLiveSettings: IGoLiveSettings, context?: TDisplayType) { const ttSettings = getDefined(goLiveSettings.platforms.tiktok); if (!this.streamingService.views.isMultiplatformMode) { - this.streamSettingsService.setSettings({ - streamType: 'rtmp_custom', - key: ttSettings.streamKey, - server: ttSettings.serverUrl, - }); + this.streamSettingsService.setSettings( + { + streamType: 'rtmp_custom', + key: ttSettings.streamKey, + server: ttSettings.serverUrl, + }, + context, + ); } this.SET_STREAM_SETTINGS(ttSettings); + this.setPlatformContext('tiktok'); } /** diff --git a/app/services/platforms/trovo.ts b/app/services/platforms/trovo.ts index 60c6d9616b46..5bf3f5c493b8 100644 --- a/app/services/platforms/trovo.ts +++ b/app/services/platforms/trovo.ts @@ -13,6 +13,7 @@ import { platformAuthorizedRequest } from './utils'; import { IGoLiveSettings } from '../streaming'; import { getDefined } from '../../util/properties-type-guards'; import Utils from '../utils'; +import { TDisplayType } from 'services/settings-v2'; interface ITrovoServiceState extends IPlatformState { settings: ITrovoStartStreamOptions; @@ -78,17 +79,22 @@ export class TrovoService return this.userService.state.auth?.platforms?.trovo?.username || ''; } - async beforeGoLive(goLiveSettings: IGoLiveSettings) { + async beforeGoLive(goLiveSettings: IGoLiveSettings, context?: TDisplayType) { const trSettings = getDefined(goLiveSettings.platforms.trovo); const key = this.state.streamKey; if (!this.streamingService.views.isMultiplatformMode) { - this.streamSettingsService.setSettings({ - streamType: 'rtmp_custom', - key, - server: this.rtmpServer, - }); + this.streamSettingsService.setSettings( + { + streamType: 'rtmp_custom', + key, + server: this.rtmpServer, + }, + context, + ); } await this.putChannelInfo(trSettings); + + this.setPlatformContext('trovo'); } fetchNewToken(): Promise { diff --git a/app/services/platforms/twitch.ts b/app/services/platforms/twitch.ts index 6d98c5c680d6..cf0c6b1980e0 100644 --- a/app/services/platforms/twitch.ts +++ b/app/services/platforms/twitch.ts @@ -18,11 +18,15 @@ import { InheritMutations, mutation } from 'services/core'; import { throwStreamError, TStreamErrorType } from 'services/streaming/stream-error'; import { BasePlatformService } from './base-platform'; import Utils from '../utils'; +import { IVideo } from 'obs-studio-node'; +import { TDisplayType } from 'services/settings-v2'; +import { TOutputOrientation } from 'services/restream'; export interface ITwitchStartStreamOptions { title: string; game?: string; tags: string[]; + mode?: TOutputOrientation; } export interface ITwitchChannelInfo extends ITwitchStartStreamOptions { @@ -75,6 +79,7 @@ export class TwitchService title: '', game: '', tags: [], + mode: undefined, }, }; @@ -159,7 +164,7 @@ export class TwitchService return this.state.settings.tags; } - async beforeGoLive(goLiveSettings?: IGoLiveSettings) { + async beforeGoLive(goLiveSettings?: IGoLiveSettings, context?: TDisplayType) { if ( this.streamSettingsService.protectedModeEnabled && this.streamSettingsService.isSafeToModifyStreamKey() @@ -171,12 +176,15 @@ export class TwitchService } this.SET_STREAM_KEY(key); if (!this.streamingService.views.isMultiplatformMode) { - this.streamSettingsService.setSettings({ - key, - platform: 'twitch', - streamType: 'rtmp_common', - server: 'auto', - }); + this.streamSettingsService.setSettings( + { + key, + platform: 'twitch', + streamType: 'rtmp_common', + server: 'auto', + }, + context, + ); } } @@ -184,6 +192,8 @@ export class TwitchService const channelInfo = goLiveSettings?.platforms.twitch; if (channelInfo) await this.putChannelInfo(channelInfo); } + + this.setPlatformContext('twitch'); } async validatePlatform() { diff --git a/app/services/platforms/youtube.ts b/app/services/platforms/youtube.ts index ad029d340f30..a8b42a95af06 100644 --- a/app/services/platforms/youtube.ts +++ b/app/services/platforms/youtube.ts @@ -21,6 +21,9 @@ import { YoutubeUploader } from './youtube/uploader'; import { lazyModule } from 'util/lazy-module'; import * as remote from '@electron/remote'; import pick from 'lodash/pick'; +import { TDisplayType } from 'services/settings-v2'; +import { IVideo } from 'obs-studio-node'; +import { TOutputOrientation } from 'services/restream'; interface IYoutubeServiceState extends IPlatformState { liveStreamingEnabled: boolean; @@ -38,6 +41,7 @@ export interface IYoutubeStartStreamOptions extends IExtraBroadcastSettings { description: string; privacyStatus?: 'private' | 'public' | 'unlisted'; scheduledStartTime?: number; + mode?: TOutputOrientation; } /** @@ -199,6 +203,7 @@ export class YoutubeService privacyStatus: 'public', selfDeclaredMadeForKids: false, thumbnail: '', + mode: undefined, }, }; @@ -271,7 +276,7 @@ export class YoutubeService this.state.liveStreamingEnabled = enabled; } - async beforeGoLive(settings: IGoLiveSettings) { + async beforeGoLive(settings: IGoLiveSettings, context?: TDisplayType) { const ytSettings = getDefined(settings.platforms.youtube); const streamToScheduledBroadcast = !!ytSettings.broadcastId; // update selected LiveBroadcast with new title and description @@ -301,17 +306,22 @@ export class YoutubeService const streamKey = stream.cdn.ingestionInfo.streamName; if (!this.streamingService.views.isMultiplatformMode) { - this.streamSettingsService.setSettings({ - platform: 'youtube', - key: streamKey, - streamType: 'rtmp_common', - server: 'rtmp://a.rtmp.youtube.com/live2', - }); + this.streamSettingsService.setSettings( + { + platform: 'youtube', + key: streamKey, + streamType: 'rtmp_common', + server: 'rtmp://a.rtmp.youtube.com/live2', + }, + context, + ); } this.UPDATE_STREAM_SETTINGS({ ...ytSettings, broadcastId: broadcast.id }); this.SET_STREAM_ID(stream.id); this.SET_STREAM_KEY(streamKey); + + this.setPlatformContext('youtube'); } /** diff --git a/app/services/restream.ts b/app/services/restream.ts index b99e730c71f2..8f4cf8061961 100644 --- a/app/services/restream.ts +++ b/app/services/restream.ts @@ -13,13 +13,19 @@ import { FacebookService } from './platforms/facebook'; import { TiktokService } from './platforms/tiktok'; import { TrovoService } from './platforms/trovo'; import * as remote from '@electron/remote'; +import * as obs from 'obs-studio-node'; +import { VideoSettingsService, TDisplayType } from './settings-v2/video'; +import { GreenService } from './green'; interface IRestreamTarget { id: number; platform: TPlatform; streamKey: string; + mode?: TOutputOrientation; } +export type TOutputOrientation = 'landscape' | 'portrait'; + interface IRestreamState { /** * Whether this user has restream enabled @@ -48,6 +54,8 @@ export class RestreamService extends StatefulService { @Inject() facebookService: FacebookService; @Inject() tiktokService: TiktokService; @Inject() trovoService: TrovoService; + @Inject() videoSettingsService: VideoSettingsService; + @Inject() greenService: GreenService; settings: IUserSettingsResponse; @@ -93,7 +101,7 @@ export class RestreamService extends StatefulService { } get chatUrl() { - const hasFBTarget = this.streamInfo.enabledPlatforms.includes('facebook'); + const hasFBTarget = this.streamInfo.enabledPlatforms.includes('facebook' as TPlatform); let fbParams = ''; if (hasFBTarget) { const fbView = this.facebookService.views; @@ -109,12 +117,27 @@ export class RestreamService extends StatefulService { } get shouldGoLiveWithRestream() { - return this.streamInfo.isMultiplatformMode; + return this.streamInfo.isMultiplatformMode || this.streamInfo.isGreen; } - fetchUserSettings(): Promise { + fetchUserSettings(mode?: 'landscape' | 'portrait'): Promise { const headers = authorizedHeaders(this.userService.apiToken); - const url = `https://${this.host}/api/v1/rst/user/settings`; + + let url; + switch (mode) { + case 'landscape': { + url = 'https://beta.streamlabs.com/api/v1/rst/user/settings'; + break; + } + case 'portrait': { + url = 'https://beta.streamlabs.com/api/v1/rst/user/settings?mode=portrait'; + break; + } + default: { + url = `https://${this.host}/api/v1/rst/user/settings`; + } + } + const request = new Request(url, { headers }); return jfetch(request); @@ -154,25 +177,32 @@ export class RestreamService extends StatefulService { return jfetch(request); } - async beforeGoLive() { - await Promise.all([this.setupIngest(), this.setupTargets()]); + async beforeGoLive(context?: TDisplayType, mode?: TOutputOrientation) { + await Promise.all([this.setupIngest(context, mode), this.setupTargets(!!mode)]); } - async setupIngest() { + async setupIngest(context?: TDisplayType, mode?: TOutputOrientation) { const ingest = (await this.fetchIngest()).server; + const settings = mode ? await this.fetchUserSettings(mode) : this.settings; // We need to move OBS to custom ingest mode before we can set the server - this.streamSettingsService.setSettings({ - streamType: 'rtmp_custom', - }); + this.streamSettingsService.setSettings( + { + streamType: 'rtmp_custom', + }, + context, + ); - this.streamSettingsService.setSettings({ - key: this.settings.streamKey, - server: ingest, - }); + this.streamSettingsService.setSettings( + { + key: settings.streamKey, + server: ingest, + }, + context, + ); } - async setupTargets() { + async setupTargets(isGreenMode?: boolean) { // delete existing targets const targets = await this.fetchTargets(); const promises = targets.map(t => this.deleteTarget(t.id)); @@ -180,15 +210,34 @@ export class RestreamService extends StatefulService { // setup new targets const newTargets = [ - ...this.streamInfo.enabledPlatforms.map(platform => { - return { - platform: platform as TPlatform, - streamKey: getPlatformService(platform).state.streamKey, - }; - }), + ...this.streamInfo.enabledPlatforms.map(platform => + isGreenMode + ? { + platform, + streamKey: getPlatformService(platform).state.streamKey, + mode: this.greenService.views.getPlatformContextName(platform) ?? 'landscape', + } + : { + platform, + streamKey: getPlatformService(platform).state.streamKey, + }, + ), ...this.streamInfo.savedSettings.customDestinations .filter(dest => dest.enabled) - .map(dest => ({ platform: 'relay' as 'relay', streamKey: `${dest.url}${dest.streamKey}` })), + .map(dest => + isGreenMode + ? { + platform: 'relay' as 'relay', + streamKey: `${dest.url}${dest.streamKey}`, + mode: + this.greenService.views.getPlatformContextName(dest.name as TPlatform) ?? + 'landscape', + } + : { + platform: 'relay' as 'relay', + streamKey: `${dest.url}${dest.streamKey}`, + }, + ), ]; // treat tiktok as a custom destination @@ -211,7 +260,13 @@ export class RestreamService extends StatefulService { ); } - async createTargets(targets: { platform: TPlatform | 'relay'; streamKey: string }[]) { + async createTargets( + targets: { + platform: TPlatform | 'relay'; + streamKey: string; + mode?: TOutputOrientation; + }[], + ) { const headers = authorizedHeaders( this.userService.apiToken, new Headers({ 'Content-Type': 'application/json' }), @@ -226,9 +281,11 @@ export class RestreamService extends StatefulService { dcProtection: false, idleTimeout: 30, label: `${target.platform} target`, + mode: target?.mode, }; }), ); + const request = new Request(url, { headers, body, method: 'POST' }); const res = await fetch(request); if (!res.ok) throw await res.json(); @@ -314,9 +371,11 @@ export class RestreamService extends StatefulService { }, }); - this.customizationService.settingsChanged.subscribe(changed => { - this.handleSettingsChanged(changed); - }); + this.customizationService.settingsChanged.subscribe( + (changed: Partial) => { + this.handleSettingsChanged(changed); + }, + ); this.chatView.webContents.loadURL(this.chatUrl); diff --git a/app/services/scene-collections/nodes/root.ts b/app/services/scene-collections/nodes/root.ts index 1489eea4951a..631909cfd055 100644 --- a/app/services/scene-collections/nodes/root.ts +++ b/app/services/scene-collections/nodes/root.ts @@ -8,6 +8,8 @@ import { VideoService } from 'services/video'; import { StreamingService } from 'services/streaming'; import { OS } from 'util/operating-systems'; import { GuestCamNode } from './guest-cam'; +import { VideoSettingsService } from 'services/settings-v2/video'; +import { SettingsService } from 'services/settings'; interface ISchema { baseResolution: { @@ -31,6 +33,8 @@ export class RootNode extends Node { @Inject() videoService: VideoService; @Inject() streamingService: StreamingService; + @Inject() videoSettingsService: VideoSettingsService; + @Inject() settingsService: SettingsService; async save(): Promise { const sources = new SourcesNode(); @@ -51,7 +55,7 @@ export class RootNode extends Node { transitions, hotkeys, guestCam, - baseResolution: this.videoService.baseResolution, + baseResolution: this.videoSettingsService.baseResolution, selectiveRecording: this.streamingService.state.selectiveRecording, operatingSystem: process.platform as OS, }; diff --git a/app/services/scenes/scene-item.ts b/app/services/scenes/scene-item.ts index 4d5096a41153..cd7db1284c49 100644 --- a/app/services/scenes/scene-item.ts +++ b/app/services/scenes/scene-item.ts @@ -215,6 +215,10 @@ export class SceneItem extends SceneItemNode { this.getObsSceneItem().blendingMethod = newSettings.blendingMethod; } + if (changed.output !== void 0 || patch.hasOwnProperty('output')) { + this.getObsSceneItem().video = newSettings.output as obs.IVideo; + } + this.UPDATE({ sceneItemId: this.sceneItemId, ...changed }); this.scenesService.itemUpdated.next(this.getModel()); diff --git a/app/services/scenes/scenes.ts b/app/services/scenes/scenes.ts index d629e24f4f7e..394065e8a9b0 100644 --- a/app/services/scenes/scenes.ts +++ b/app/services/scenes/scenes.ts @@ -94,6 +94,7 @@ export interface IPartialSettings { scaleFilter?: EScaleType; blendingMode?: EBlendingMode; blendingMethod?: EBlendingMethod; + output?: obs.IVideo; // for obs.ISceneItem, this property is video } export interface ISceneItem extends ISceneItemSettings, ISceneItemNode { @@ -101,6 +102,7 @@ export interface ISceneItem extends ISceneItemSettings, ISceneItemNode { sourceId: string; obsSceneItemId: number; sceneNodeType: 'item'; + output?: obs.IVideo; // for obs.ISceneItem, this property is video } export interface ISceneItemActions { @@ -181,6 +183,11 @@ class ScenesViews extends ViewHandler { return null; } + getSceneItemsBySceneId(sceneId: string): SceneItem[] { + const scene: Scene | null = this.getScene(sceneId); + return scene ? scene.getItems() : ([] as SceneItem[]); + } + /** * Returns an array of all scene items across all scenes * referencing the given source id. @@ -207,6 +214,11 @@ class ScenesViews extends ViewHandler { } return null; } + + getNodeVisibility(sceneNodeId: string) { + const nodeModel: TSceneNode | null = this.getSceneNode(sceneNodeId); + return nodeModel instanceof SceneItem ? nodeModel?.visible : null; + } } export class ScenesService extends StatefulService { diff --git a/app/services/settings-manager.ts b/app/services/settings-manager.ts index 6a34fca57ca5..10e09acf37cb 100644 --- a/app/services/settings-manager.ts +++ b/app/services/settings-manager.ts @@ -1,16 +1,13 @@ -import { Service, ViewHandler } from 'services/core'; +import { Inject, Service, ViewHandler } from 'services/core'; +import { GreenService } from './green'; import * as obs from '../../obs-api'; -/* -Eventually this service will be in charge of storing and managing settings profiles -once the new persistant storage system is finalized. For now it just retrieves settings -from the backend. -*/ +// /* +// Eventually this service will be in charge of storing and managing settings profiles +// once the new persistant storage system is finalized. For now it just retrieves settings +// from the backend. +// */ class SettingsManagerViews extends ViewHandler<{}> {} -export class SettingsManagerService extends Service { - get videoSettings() { - return obs.VideoFactory.legacySettings; - } -} +export class SettingsManagerService extends Service {} diff --git a/app/services/settings-v2/default-settings-data.ts b/app/services/settings-v2/default-settings-data.ts new file mode 100644 index 000000000000..7e86d66f315a --- /dev/null +++ b/app/services/settings-v2/default-settings-data.ts @@ -0,0 +1,15 @@ +import { EVideoFormat, EColorSpace, ERangeType, EScaleType, EFPSType } from 'obs-studio-node'; + +export const greenDisplayData = { + fpsNum: 60, + fpsDen: 2, + baseWidth: 400, + baseHeight: 700, + outputWidth: 400, + outputHeight: 700, + outputFormat: EVideoFormat.I420, + colorspace: EColorSpace.CS709, + range: ERangeType.Full, + scaleType: EScaleType.Lanczos, + fpsType: EFPSType.Fractional, +}; diff --git a/app/services/settings-v2/index.ts b/app/services/settings-v2/index.ts new file mode 100644 index 000000000000..0bc8d05f8042 --- /dev/null +++ b/app/services/settings-v2/index.ts @@ -0,0 +1,2 @@ +export * from './default-settings-data'; +export * from './video'; diff --git a/app/services/settings-v2/video.ts b/app/services/settings-v2/video.ts index 9b5b34df9a12..5e2404a58268 100644 --- a/app/services/settings-v2/video.ts +++ b/app/services/settings-v2/video.ts @@ -3,26 +3,54 @@ import { Inject } from 'services/core/injector'; import { InitAfter } from 'services/core'; import { mutation, StatefulService } from '../core/stateful-service'; import * as obs from '../../../obs-api'; -import { SettingsManagerService } from 'services/settings-manager'; +import { GreenService } from 'services/green'; +import { SettingsService } from 'services/settings'; + +/** + * Display Types + * + * Add display type options by adding the display name to the displays array + * and the context name to the context name map. + */ +const displays = ['default', 'horizontal', 'green'] as const; +export type TDisplayType = typeof displays[number]; export function invalidFps(num: number, den: number) { return num / den > 1000 || num / den < 1; } -@InitAfter('UserService') -export class VideoSettingsService extends StatefulService<{ videoContext: obs.IVideo }> { - @Inject() settingsManagerService: SettingsManagerService; +export interface IVideoSetting { + default: obs.IVideoInfo; + horizontal: obs.IVideoInfo; + green: obs.IVideoInfo; +} + +export class VideoSettingsService extends StatefulService { + @Inject() greenService: GreenService; + @Inject() settingsService: SettingsService; initialState = { - videoContext: null as obs.IVideo, + default: null as obs.IVideoInfo, + horizontal: null as obs.IVideoInfo, + green: null as obs.IVideoInfo, }; init() { this.establishVideoContext(); + if (this.greenService.views.activeDisplays.green) { + this.establishVideoContext('green'); + } } + contexts = { + default: null as obs.IVideo, + horizontal: null as obs.IVideo, + green: null as obs.IVideo, + }; + get videoSettingsValues() { - const context = this.state.videoContext; + const context = this.state.horizontal; + return { baseRes: `${context.baseWidth}x${context.baseHeight}`, outputRes: `${context.outputWidth}x${context.outputHeight}`, @@ -34,49 +62,126 @@ export class VideoSettingsService extends StatefulService<{ videoContext: obs.IV fpsInt: context.fpsNum, }; } + get baseResolution() { + const context = this.state.horizontal; + + const [widthStr, heightStr] = this.settingsService.views.values.Video.Base.split('x'); + + return { + width: context?.baseWidth ?? parseInt(widthStr, 10), + height: context?.baseHeight ?? parseInt(heightStr, 10), + }; + } + + get isGreen(): boolean { + return false; + } get videoSettings() { - return this.settingsManagerService.videoSettings; + return this.greenService.views.videoSettings; } - migrateSettings() { - Object.keys(this.videoSettings).forEach((key: keyof obs.IVideo) => { - this.SET_VIDEO_SETTING(key, this.videoSettings[key]); - }); + migrateSettings(display: TDisplayType = 'horizontal') { + this.SET_VIDEO_CONTEXT(display, true); - if (invalidFps(this.state.videoContext.fpsNum, this.state.videoContext.fpsDen)) { - this.setVideoSetting('fpsNum', 30); - this.setVideoSetting('fpsDen', 1); + if (display === 'horizontal') { + Object.keys(this.contexts.horizontal.video).forEach((key: keyof obs.IVideo) => { + this.SET_VIDEO_SETTING(key, this.contexts.horizontal.video[key]); + }); + } else { + const data = this.videoSettings.green; + + Object.keys(data).forEach( + (key: keyof obs.IAdvancedStreaming | keyof obs.ISimpleStreaming) => { + this.SET_VIDEO_SETTING(key, data[key], display); + }, + ); + } + + if (invalidFps(this.contexts[display].video.fpsNum, this.contexts[display].video.fpsDen)) { + this.createDefaultFps(display); } } - establishVideoContext() { - if (this.state.videoContext) return; + establishVideoContext(display: TDisplayType = 'horizontal') { + if (this.contexts[display]) return; - this.SET_VIDEO_CONTEXT(); + this.contexts[display] = obs.VideoFactory.create(); + this.SET_VIDEO_CONTEXT(display, true); + this.migrateSettings(display); + this.contexts[display].video = this.state[display]; - this.migrateSettings(); - obs.VideoFactory.videoContext = this.state.videoContext; + return !!this.contexts[display]; + } + + destroyVideoContext(display: TDisplayType = 'horizontal') { + if (this.contexts[display]) { + const context: obs.IVideo = this.contexts[display]; + context.destroy(); + } + + this.contexts[display] = null as obs.IVideo; + this.REMOVE_CONTEXT(display); + + return !!this.contexts[display]; + } + + createDefaultFps(display: TDisplayType = 'horizontal') { + this.setVideoSetting('fpsNum', 30, display); + this.setVideoSetting('fpsDen', 1, display); } @debounce(200) - updateObsSettings() { - obs.VideoFactory.videoContext = this.state.videoContext; - obs.VideoFactory.legacySettings = this.state.videoContext; + updateObsSettings(display: TDisplayType = 'horizontal') { + this.contexts[display].video = this.state[display]; + this.contexts[display].legacySettings = this.state[display]; + } + + resetToDefaultContext() { + for (const context in this.contexts) { + if (context !== 'horizontal') { + this.destroyVideoContext(context as TDisplayType); + } + } } - setVideoSetting(key: string, value: unknown) { - this.SET_VIDEO_SETTING(key, value); - this.updateObsSettings(); + setVideoSetting(key: string, value: unknown, display: TDisplayType = 'horizontal') { + this.SET_VIDEO_SETTING(key, value, display); + + if (display === 'horizontal') { + this.updateObsSettings(); + } else if (display === 'green') { + // if the display is green, also update the persisted settings + this.updateObsSettings('green'); + this.greenService.setVideoSetting({ [key]: value }); + } + } + + shutdown() { + displays.forEach(display => { + const context = this.contexts[display]; + if (context) { + context.destroy(); + } + }); } @mutation() - SET_VIDEO_CONTEXT() { - this.state.videoContext = {} as obs.IVideo; + SET_VIDEO_SETTING(key: string, value: unknown, display: TDisplayType = 'horizontal') { + this.state[display][key] = value; + } + + @mutation() + SET_VIDEO_CONTEXT(display: TDisplayType = 'horizontal', reset?: boolean) { + if (!reset) { + this.state[display] = this.contexts[display].video; + } else { + this.state[display] = {} as obs.IVideoInfo; + } } @mutation() - SET_VIDEO_SETTING(key: string, value: unknown) { - this.state.videoContext[key] = value; + REMOVE_CONTEXT(display: TDisplayType) { + this.state[display] = null as obs.IVideoInfo; } } diff --git a/app/services/settings/streaming/stream-settings.ts b/app/services/settings/streaming/stream-settings.ts index 47d19b5a76fb..0fb7b9a83446 100644 --- a/app/services/settings/streaming/stream-settings.ts +++ b/app/services/settings/streaming/stream-settings.ts @@ -9,7 +9,9 @@ import cloneDeep from 'lodash/cloneDeep'; import { TwitchService } from 'services/platforms/twitch'; import { PlatformAppsService } from 'services/platform-apps'; import { IGoLiveSettings, IPlatformFlags } from 'services/streaming'; +import { TDisplayType } from 'services/settings-v2/video'; import Vue from 'vue'; +import { TOutputOrientation } from 'services/restream'; interface ISavedGoLiveSettings { platforms: { @@ -26,6 +28,7 @@ export interface ICustomStreamDestination { url: string; streamKey?: string; enabled: boolean; + mode?: TOutputOrientation; } /** @@ -123,7 +126,8 @@ export class StreamSettingsService extends PersistentStatefulService) { + setSettings(patch: Partial, context?: TDisplayType) { + const streamName = !context || context === 'horizontal' ? 'Stream' : 'StreamSecond'; // save settings to localStorage const localStorageSettings: (keyof IStreamSettingsState)[] = [ 'protectedModeEnabled', @@ -146,9 +150,11 @@ export class StreamSettingsService extends PersistentStatefulService { if (parameter.name === 'streamType' && patch.streamType !== void 0) { parameter.value = patch.streamType; + // we should immediately save the streamType in OBS if it's changed // otherwise OBS will not save 'key' and 'server' values - this.settingsService.setSettings('Stream', streamFormData); + + this.settingsService.setSettings(streamName, streamFormData); } }); }); @@ -157,6 +163,7 @@ export class StreamSettingsService extends PersistentStatefulService ['platform', 'key', 'server'].includes(key), ); + if (!mustUpdateObsSettings) return; streamFormData = cloneDeep(this.views.obsStreamSettings); @@ -175,7 +182,8 @@ export class StreamSettingsService extends PersistentStatefulService) { @@ -189,7 +197,7 @@ export class StreamSettingsService extends PersistentStatefulService extends ViewHandler { return this.streamingState.streamingStatus !== 'offline'; } + get isGreen(): boolean { + return Services.VideoSettingsService.isGreen; + } + + get activeDisplayPlatforms() { + return Services.GreenService.views.activeDisplayPlatforms; + } + + get contextsToStream() { + return Services.GreenService.views.contextsToStream; + } + + get activeDisplays() { + return Services.GreenService.views.activeDisplays; + } + + get hasGreenContext() { + return !!Services.VideoSettingsService.contexts.green; + } + + getPlatformDisplay(platform: TPlatform) { + return Services.GreenService.views.getPlatformDisplay(platform); + } + /** * Returns total viewer count for all enabled platforms */ diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index 8c013fe42c5a..372291595977 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -31,7 +31,7 @@ import { import { VideoEncodingOptimizationService } from 'services/video-encoding-optimizations'; import { CustomizationService } from 'services/customization'; import { StreamSettingsService } from '../settings/streaming'; -import { RestreamService } from 'services/restream'; +import { RestreamService, TOutputOrientation } from 'services/restream'; import Utils from 'services/utils'; import cloneDeep from 'lodash/cloneDeep'; import isEqual from 'lodash/isEqual'; @@ -46,6 +46,7 @@ import * as remote from '@electron/remote'; import { RecordingModeService } from 'services/recording-mode'; import { MarkersService } from 'services/markers'; import { byOS, OS } from 'util/operating-systems'; +import { TDisplayType, VideoSettingsService } from 'services/settings-v2'; enum EOBSOutputType { Streaming = 'streaming', @@ -58,6 +59,7 @@ enum EOBSOutputSignal { Start = 'start', Stopping = 'stopping', Stop = 'stop', + Deactivate = 'deactivate', Reconnect = 'reconnect', ReconnectSuccess = 'reconnect_success', Wrote = 'wrote', @@ -69,6 +71,7 @@ interface IOBSOutputSignalInfo { signal: EOBSOutputSignal; code: obs.EOutputCode; error: string; + service: string; // 'default' | 'green' } export class StreamingService @@ -87,12 +90,14 @@ export class StreamingService @Inject() private growService: GrowService; @Inject() private recordingModeService: RecordingModeService; @Inject() private markersService: MarkersService; + @Inject() private videoSettingsService: VideoSettingsService; streamingStatusChange = new Subject(); recordingStatusChange = new Subject(); replayBufferStatusChange = new Subject(); replayBufferFileWrite = new Subject(); streamInfoChanged = new Subject>(); + signalInfoChanged = new Subject(); // Dummy subscription for stream deck streamingStateChange = new Subject(); @@ -123,6 +128,7 @@ export class StreamingService tiktok: 'not-started', trovo: 'not-started', setupMultistream: 'not-started', + setupGreen: 'not-started', startVideoTransmission: 'not-started', postTweet: 'not-started', }, @@ -273,20 +279,11 @@ export class StreamingService try { // don't update settings for twitch in unattendedMode const settingsForPlatform = platform === 'twitch' && unattendedMode ? undefined : settings; - await this.runCheck(platform, () => service.beforeGoLive(settingsForPlatform)); + + const context = this.views.getPlatformDisplay(platform); + await this.runCheck(platform, () => service.beforeGoLive(settingsForPlatform, context)); } catch (e: unknown) { - console.error('Error running beforeGoLive for plarform', e); - // cast all PLATFORM_REQUEST_FAILED errors to SETTINGS_UPDATE_FAILED - if (e instanceof StreamError) { - e.type = - (e.type as TStreamErrorType) === 'PLATFORM_REQUEST_FAILED' - ? 'SETTINGS_UPDATE_FAILED' - : e.type || 'UNKNOWN_ERROR'; - this.setError(e, platform); - } else { - this.setError('SETTINGS_UPDATE_FAILED', platform); - } - return; + this.handleSetupPlatformError(e, platform); } } @@ -322,6 +319,45 @@ export class StreamingService } } + // setup green + if (this.views.isGreen) { + const displayPlatforms = this.views.activeDisplayPlatforms; + + for (const display in displayPlatforms) { + if (displayPlatforms[display].length > 1) { + let ready = false; + try { + await this.runCheck( + 'setupGreen', + async () => (ready = await this.restreamService.checkStatus()), + ); + } catch (e: unknown) { + console.error('Error fetching restreaming service', e); + } + // Assume restream is down + if (!ready) { + this.setError('RESTREAM_DISABLED'); + return; + } + + // update restream settings + try { + await this.runCheck('setupGreen', async () => { + // enable restream on the backend side + if (!this.restreamService.state.enabled) await this.restreamService.setEnabled(true); + + const mode: TOutputOrientation = display === 'horizontal' ? 'landscape' : 'portrait'; + await this.restreamService.beforeGoLive(display as TDisplayType, mode); + }); + } catch (e: unknown) { + console.error('Failed to setup restream', e); + this.setError('RESTREAM_SETUP_FAILED'); + return; + } + } + } + } + // apply optimized settings const optimizer = this.videoEncodingOptimizationService; if (optimizer.state.useOptimizedProfile && settings.optimizedProfile) { @@ -372,6 +408,21 @@ export class StreamingService } } + private handleSetupPlatformError(e: unknown, platform: TPlatform) { + console.error('Error running beforeGoLive for platform', e); + // cast all PLATFORM_REQUEST_FAILED errors to SETTINGS_UPDATE_FAILED + if (e instanceof StreamError) { + e.type = + (e.type as TStreamErrorType) === 'PLATFORM_REQUEST_FAILED' + ? 'SETTINGS_UPDATE_FAILED' + : e.type || 'UNKNOWN_ERROR'; + this.setError(e, platform); + } else { + this.setError('SETTINGS_UPDATE_FAILED', platform); + } + return; + } + private recordAfterStreamStartAnalytics(settings: IGoLiveSettings) { if (settings.customDestinations.filter(dest => dest.enabled).length) { this.usageStatisticsService.recordFeatureUsage('CustomStreamDestination'); @@ -614,7 +665,45 @@ export class StreamingService this.powerSaveId = remote.powerSaveBlocker.start('prevent-display-sleep'); - obs.NodeObs.OBS_service_startStreaming(); + if (this.views.contextsToStream.length > 1 && this.views.enabledPlatforms.length > 1) { + const horizontalContext = this.videoSettingsService.contexts.horizontal; + const greenContext = this.videoSettingsService.contexts.green; + + obs.NodeObs.OBS_service_setVideoInfo(horizontalContext, 'horizontal'); + obs.NodeObs.OBS_service_setVideoInfo(greenContext, 'green'); + + const signalChanged = this.signalInfoChanged.subscribe((signalInfo: IOBSOutputSignalInfo) => { + if (signalInfo.service === 'default') { + if (signalInfo.code !== 0) { + obs.NodeObs.OBS_service_stopStreaming(true, 'horizontal'); + obs.NodeObs.OBS_service_stopStreaming(true, 'green'); + } + + if (signalInfo.signal === EOBSOutputSignal.Start) { + obs.NodeObs.OBS_service_startStreaming('green'); + signalChanged.unsubscribe(); + } + } + }); + + obs.NodeObs.OBS_service_startStreaming('horizontal'); + // sleep for 1 second to allow the first stream to start + await new Promise(resolve => setTimeout(resolve, 1000)); + } else { + if (this.views.activeDisplays.green && this.views.contextsToStream.includes('green')) { + obs.NodeObs.OBS_service_setVideoInfo(this.videoSettingsService.contexts.green, 'green'); + obs.NodeObs.OBS_service_startStreaming('green'); + } else if (this.views.activeDisplays.horizontal && this.views.hasGreenContext) { + // if the green context is active, explicitly set the horizontal context info + obs.NodeObs.OBS_service_setVideoInfo( + this.videoSettingsService.contexts.horizontal, + 'horizontal', + ); + obs.NodeObs.OBS_service_startStreaming('horizontal'); + } else { + obs.NodeObs.OBS_service_startStreaming(); + } + } const recordWhenStreaming = this.streamSettingsService.settings.recordWhenStreaming; @@ -685,7 +774,29 @@ export class StreamingService remote.powerSaveBlocker.stop(this.powerSaveId); } - obs.NodeObs.OBS_service_stopStreaming(false); + if (this.views.contextsToStream.length > 1 && this.views.enabledPlatforms.length > 1) { + const signalChanged = this.signalInfoChanged.subscribe( + (signalInfo: IOBSOutputSignalInfo) => { + if ( + signalInfo.service === 'default' && + signalInfo.signal === EOBSOutputSignal.Deactivate + ) { + obs.NodeObs.OBS_service_stopStreaming(false, 'green'); + signalChanged.unsubscribe(); + } + }, + ); + + obs.NodeObs.OBS_service_stopStreaming(false, 'horizontal'); + + await new Promise(resolve => setTimeout(resolve, 1000)); + } else { + // if (this.views.activeDisplays.green && this.views.contextsToStream.includes('green')) { + // obs.NodeObs.OBS_service_stopStreaming(false, 'green'); + // } else { + obs.NodeObs.OBS_service_stopStreaming(false, 'horizontal'); + // } + } const keepRecording = this.streamSettingsService.settings.keepRecordingWhenStreamStops; if (!keepRecording && this.state.recordingStatus === ERecordingState.Recording) { @@ -707,7 +818,13 @@ export class StreamingService } if (this.state.streamingStatus === EStreamingState.Ending) { - obs.NodeObs.OBS_service_stopStreaming(true); + if (this.views.contextsToStream.length > 1) { + obs.NodeObs.OBS_service_stopStreaming(true, 'horizontal'); + obs.NodeObs.OBS_service_stopStreaming(true, 'green'); + } else { + const contextName = this.views.contextsToStream[0]; + obs.NodeObs.OBS_service_stopStreaming(true, contextName); + } return Promise.resolve(); } } diff --git a/app/services/video.ts b/app/services/video.ts index 919bd5ae20f4..af896bf6fadc 100644 --- a/app/services/video.ts +++ b/app/services/video.ts @@ -6,10 +6,11 @@ import Utils from './utils'; import { WindowsService } from './windows'; import { ScalableRectangle } from '../util/ScalableRectangle'; import { Subscription } from 'rxjs'; -import { SelectionService } from 'services/selection'; +import { ISelectionState, SelectionService } from 'services/selection'; import { byOS, OS, getOS } from 'util/operating-systems'; import * as remote from '@electron/remote'; import { onUnload } from 'util/unload'; +import { TDisplayType, VideoSettingsService } from './settings-v2'; // TODO: There are no typings for nwr let nwr: any; @@ -28,6 +29,7 @@ export interface IDisplayOptions { slobsWindowId?: string; paddingColor?: IRGBColor; renderingMode?: number; + type?: TDisplayType; } export class Display { @@ -68,6 +70,8 @@ export class Display { cancelUnload: () => void; + type?: TDisplayType; + constructor(public name: string, options: IDisplayOptions = {}) { this.sourceId = options.sourceId; this.electronWindowId = options.electronWindowId || remote.getCurrentWindow().id; @@ -80,10 +84,13 @@ export class Display { this.currentScale = this.windowsService.state[this.slobsWindowId].scaleFactor; + this.type = options?.type ?? 'horizontal'; + this.videoService.actions.createOBSDisplay( this.electronWindowId, name, this.renderingMode, + this.type, this.sourceId, ); @@ -96,9 +103,11 @@ export class Display { } // also sync girdlines when selection changes - this.selectionSubscription = this.selectionService.updated.subscribe(state => { - this.switchGridlines(state.selectedIds.length <= 1); - }); + this.selectionSubscription = this.selectionService.updated.subscribe( + (state: ISelectionState) => { + this.switchGridlines(state.selectedIds.length <= 1); + }, + ); if (options.paddingColor) { this.videoService.actions.setOBSDisplayPaddingColor( @@ -289,6 +298,7 @@ export class Display { export class VideoService extends Service { @Inject() settingsService: SettingsService; + @Inject() videoSettingsService: VideoSettingsService; init() { this.settingsService.loadSettingsIntoStore(); @@ -312,9 +322,8 @@ export class VideoService extends Service { } get baseResolution() { - const [widthStr, heightStr] = this.settingsService.views.values.Video.Base.split('x'); - const width = parseInt(widthStr, 10); - const height = parseInt(heightStr, 10); + const width = this.videoSettingsService.baseResolution.width; + const height = this.videoSettingsService.baseResolution.height; return { width, @@ -336,22 +345,29 @@ export class VideoService extends Service { createOBSDisplay( electronWindowId: number, name: string, - remderingMode: number, + renderingMode: number, + type: TDisplayType, sourceId?: string, ) { const electronWindow = remote.BrowserWindow.fromId(electronWindowId); + const context = type ? this.videoSettingsService.contexts[type] : undefined; if (sourceId) { + const context = type ? this.videoSettingsService.contexts[type] : undefined; obs.NodeObs.OBS_content_createSourcePreviewDisplay( electronWindow.getNativeWindowHandle(), sourceId, name, + false, + context, ); } else { obs.NodeObs.OBS_content_createDisplay( electronWindow.getNativeWindowHandle(), name, - remderingMode, + renderingMode, + false, + context, ); } } diff --git a/scripts/install-native-deps.js b/scripts/install-native-deps.js index 0a2f3bf862eb..9c5429b20263 100644 --- a/scripts/install-native-deps.js +++ b/scripts/install-native-deps.js @@ -89,7 +89,7 @@ async function runScript() { } else if (process.arch == 'x64') { arch = '-x86_64'; } else { - throw 'CPU architecture not supported.'; + throw 'CPU architecture not supported.'; } } else { throw 'Platform not supported.'; diff --git a/scripts/repositories.json b/scripts/repositories.json index 75ff5425d636..a9787beaf001 100644 --- a/scripts/repositories.json +++ b/scripts/repositories.json @@ -1,68 +1,68 @@ { - "root": [ - { - "name": "obs-studio-node", - "url": "https://s3-us-west-2.amazonaws.com/obsstudionodes3.streamlabs.com/", - "archive": "osn-[VERSION]-release-[OS][ARCH].tar.gz", - "version": "0.23.72", - "win64": true, - "osx": true - }, - { - "name": "node-libuiohook", - "url": "https://slobs-node-libuiohook.s3-us-west-2.amazonaws.com/", - "archive": "node-libuiohook-[VERSION]-[OS][ARCH].tar.gz", - "version": "1.1.13", - "win64": true, - "osx": true - }, - { - "name": "node-fontinfo", - "url": "https://slobs-node-fontinfo.s3-us-west-2.amazonaws.com/", - "archive": "node-fontinfo-[VERSION]-[OS][ARCH].tar.gz", - "version": "1.1.8", - "win64": true, - "osx": true - }, - { - "name": "font-manager", - "url": "https://slobs-font-manager.s3-us-west-2.amazonaws.com/", - "archive": "font-manager-[VERSION]-[OS][ARCH].tar.gz", - "version": "1.2.6", - "win64": true, - "osx": true - }, - { - "name": "crash-handler", - "url": "https://slobs-crash-handler.s3-us-west-2.amazonaws.com/", - "archive": "crash-handler-[VERSION]-[OS][ARCH].tar.gz", - "version": "1.2.16", - "win64": true, - "osx": true - }, - { - "name": "node-window-rendering", - "url": "https://slobs-node-window-rendering.s3-us-west-2.amazonaws.com/", - "archive": "node-window-rendering-[VERSION]-[OS][ARCH].tar.gz", - "version": "1.0.18", - "win64": false, - "osx": true - }, - { - "name": "game_overlay", - "url": "https://obs-studio-deployment.s3-us-west-2.amazonaws.com/", - "archive": "game-overlay-[VERSION]-[OS].tar.gz", - "version": "0.0.59", - "win64": true, - "osx": false - }, - { - "name": "color-picker", - "url": "https://obs-studio-deployment.s3-us-west-2.amazonaws.com/", - "archive": "color-picker-[VERSION]-[OS].tar.gz", - "version": "1.3.4", - "win64": true, - "osx": false - } - ] -} \ No newline at end of file + "root": [ + { + "name": "obs-studio-node", + "url": "https://s3-us-west-2.amazonaws.com/obsstudionodes3.streamlabs.com/", + "archive": "osn-[VERSION]-release-[OS][ARCH].tar.gz", + "version": "0.23.71do5", + "win64": true, + "osx": true + }, + { + "name": "node-libuiohook", + "url": "https://slobs-node-libuiohook.s3-us-west-2.amazonaws.com/", + "archive": "node-libuiohook-[VERSION]-[OS][ARCH].tar.gz", + "version": "1.1.13", + "win64": true, + "osx": true + }, + { + "name": "node-fontinfo", + "url": "https://slobs-node-fontinfo.s3-us-west-2.amazonaws.com/", + "archive": "node-fontinfo-[VERSION]-[OS][ARCH].tar.gz", + "version": "1.1.8", + "win64": true, + "osx": true + }, + { + "name": "font-manager", + "url": "https://slobs-font-manager.s3-us-west-2.amazonaws.com/", + "archive": "font-manager-[VERSION]-[OS][ARCH].tar.gz", + "version": "1.2.6", + "win64": true, + "osx": true + }, + { + "name": "crash-handler", + "url": "https://slobs-crash-handler.s3-us-west-2.amazonaws.com/", + "archive": "crash-handler-[VERSION]-[OS][ARCH].tar.gz", + "version": "1.2.16", + "win64": true, + "osx": true + }, + { + "name": "node-window-rendering", + "url": "https://slobs-node-window-rendering.s3-us-west-2.amazonaws.com/", + "archive": "node-window-rendering-[VERSION]-[OS][ARCH].tar.gz", + "version": "1.0.18", + "win64": false, + "osx": true + }, + { + "name": "game_overlay", + "url": "https://obs-studio-deployment.s3-us-west-2.amazonaws.com/", + "archive": "game-overlay-[VERSION]-[OS].tar.gz", + "version": "0.0.59", + "win64": true, + "osx": false + }, + { + "name": "color-picker", + "url": "https://obs-studio-deployment.s3-us-west-2.amazonaws.com/", + "archive": "color-picker-[VERSION]-[OS].tar.gz", + "version": "1.3.4", + "win64": true, + "osx": false + } + ] +} diff --git a/test/regular/settings/video.ts b/test/regular/settings/video.ts index 0ba10dc51209..d2c23933422e 100644 --- a/test/regular/settings/video.ts +++ b/test/regular/settings/video.ts @@ -6,23 +6,33 @@ useWebdriver(); test('Populates video settings', async t => { await showSettingsWindow('Video'); - const { assertInputOptions } = useForm(); await t.notThrowsAsync(async () => { - await assertInputOptions('scaleType', 'Bicubic (Sharpened scaling, 16 samples)', [ + await assertInputOptions('scaleType', 'Bilinear (Fastest, but blurry if scaling)', [ 'Bilinear (Fastest, but blurry if scaling)', 'Bicubic (Sharpened scaling, 16 samples)', 'Lanczos (Sharpened scaling, 32 samples)', ]); - await assertInputOptions('fpsType', 'Common FPS Values', [ + await assertInputOptions('fpsType', 'Integer FPS Values', [ 'Common FPS Values', 'Integer FPS Values', 'Fractional FPS Values', ]); + }); +}); + +test('Populates common fps values', async t => { + await showSettingsWindow('Video'); + const videoSettingsForm = useForm('video-settings'); + + await videoSettingsForm.fillForm({ + fpsType: 'Common FPS Values', + }); - await assertInputOptions('fpsCom', '30', [ + await t.notThrowsAsync(async () => { + await videoSettingsForm.assertInputOptions('fpsCom', '30', [ '10', '20', '24 NTSC',