diff --git a/app/components-react/shared/PerformanceMetrics.tsx b/app/components-react/shared/PerformanceMetrics.tsx index 345f439ac85e..c916df0c5a4a 100644 --- a/app/components-react/shared/PerformanceMetrics.tsx +++ b/app/components-react/shared/PerformanceMetrics.tsx @@ -7,9 +7,9 @@ import styles from './PerformanceMetrics.m.less'; import { $t } from '../../services/i18n'; import { useRealmObject } from 'components-react/hooks/realm'; import { IPinnedStatistics } from 'services/customization'; +import { getPerformanceMetricMetadata } from './performance-metrics'; type TPerformanceMetricsMode = 'full' | 'limited'; -const METRICS = ['cpu', 'fps', 'droppedFrames', 'bandwidth'] as (keyof IPinnedStatistics)[]; function pinTooltip(mode: TPerformanceMetricsMode, stat: string) { return mode === 'full' ? $t('Click to add %{stat} info to your footer', { stat }) : ''; @@ -29,7 +29,7 @@ export default memo(function PerformanceMetrics(props: { mode: TPerformanceMetricsMode; className?: string; }) { - const { CustomizationService, PerformanceService } = Services; + const { CustomizationService, PerformanceService, StreamingService } = Services; const pinnedStats = useRealmObject(CustomizationService.state.pinnedStatistics); @@ -40,27 +40,29 @@ export default memo(function PerformanceMetrics(props: { droppedFrames: PerformanceService.views.droppedFrames, percentDropped: PerformanceService.views.percentDropped, bandwidth: PerformanceService.views.bandwidth, + bandwidthByDisplay: PerformanceService.views.bandwidthByDisplay, + isDualOutputMode: StreamingService.views.isDualOutputMode, }), false, ); const metadata = useMemo( - () => ({ - cpu: { value: `${v.cpuPercent}%`, label: $t('CPU'), icon: 'icon-cpu' }, - fps: { value: v.frameRate, label: 'FPS', icon: 'icon-fps' }, - droppedFrames: { - value: `${v.droppedFrames} (${v.percentDropped}%)`, - label: $t('Dropped Frames'), - icon: 'icon-dropped-frames', - }, - bandwidth: { value: v.bandwidth, label: 'kb/s', icon: 'icon-bitrate' }, - }), - [v.bandwidth, v.cpuPercent, v.droppedFrames, v.frameRate, v.percentDropped], + () => getPerformanceMetricMetadata(v), + [ + v.bandwidth, + v.bandwidthByDisplay.horizontal, + v.bandwidthByDisplay.vertical, + v.cpuPercent, + v.droppedFrames, + v.frameRate, + v.isDualOutputMode, + v.percentDropped, + ], ); const shownCells = useMemo( - () => METRICS.filter(val => props.mode === 'full' || pinnedStats[val]), - [props.mode, pinnedStats], + () => metadata.filter(metric => props.mode === 'full' || pinnedStats[metric.attribute]), + [metadata, props.mode, pinnedStats], ); const updatePinnedStats = useCallback( @@ -80,17 +82,16 @@ export default memo(function PerformanceMetrics(props: { props.className, )} > - {shownCells.map((attribute: keyof IPinnedStatistics) => { - const data = metadata[attribute]; + {shownCells.map(data => { return ( ); diff --git a/app/components-react/shared/performance-metrics.ts b/app/components-react/shared/performance-metrics.ts new file mode 100644 index 000000000000..7dd288014800 --- /dev/null +++ b/app/components-react/shared/performance-metrics.ts @@ -0,0 +1,71 @@ +import { IPinnedStatistics } from 'services/customization'; +import { $t } from 'services/i18n'; + +export interface IPerformanceMetricValues { + cpuPercent: string; + frameRate: string; + droppedFrames: number; + percentDropped: string; + bandwidth: string; + bandwidthByDisplay: { + horizontal: string; + vertical: string; + }; + isDualOutputMode: boolean; +} + +export interface IPerformanceMetricMetadata { + key: string; + attribute: keyof IPinnedStatistics; + value: string | number; + label: string; + icon: string; +} + +export function getPerformanceMetricMetadata( + values: IPerformanceMetricValues, +): IPerformanceMetricMetadata[] { + const metrics: IPerformanceMetricMetadata[] = [ + { + key: 'cpu', + attribute: 'cpu', + value: `${values.cpuPercent}%`, + label: $t('CPU'), + icon: 'icon-cpu', + }, + { + key: 'fps', + attribute: 'fps', + value: values.frameRate, + label: 'FPS', + icon: 'icon-fps', + }, + { + key: 'droppedFrames', + attribute: 'droppedFrames', + value: `${values.droppedFrames} (${values.percentDropped}%)`, + label: $t('Dropped Frames'), + icon: 'icon-dropped-frames', + }, + ]; + + if (values.isDualOutputMode) { + metrics.push({ + key: 'bandwidth', + attribute: 'bandwidth', + value: `H: ${values.bandwidthByDisplay.horizontal} V: ${values.bandwidthByDisplay.vertical}`, + label: 'kb/s', + icon: 'icon-bitrate', + }); + } else { + metrics.push({ + key: 'bandwidth', + attribute: 'bandwidth', + value: values.bandwidth, + label: 'kb/s', + icon: 'icon-bitrate', + }); + } + + return metrics; +} diff --git a/app/services/performance.ts b/app/services/performance.ts index efb497b77c65..2b9fbed1fc37 100644 --- a/app/services/performance.ts +++ b/app/services/performance.ts @@ -16,6 +16,10 @@ import { StreamingService, EStreamingState } from 'services/streaming'; import { VideoSettingsService } from 'services/settings-v2/video'; import { DualOutputService } from 'services/dual-output'; import { UsageStatisticsService } from './usage-statistics'; +import { + createEmptyDisplayStats, + TStreamingPerformanceDisplayStatsByDisplay, +} from './streaming/streaming-statistics'; interface IPerformanceState { CPU: number; @@ -28,6 +32,7 @@ interface IPerformanceState { numberEncodedFrames: number; numberRenderedFrames: number; streamingBandwidth: number; + streamingBandwidthByDisplay: TStreamingPerformanceDisplayStatsByDisplay; frameRate: number; } @@ -87,6 +92,13 @@ class PerformanceServiceViews extends ViewHandler { return (this.state.streamingBandwidth ?? 0).toFixed(0); } + get bandwidthByDisplay() { + return { + horizontal: (this.state.streamingBandwidthByDisplay?.horizontal.kbitsPerSec ?? 0).toFixed(0), + vertical: (this.state.streamingBandwidthByDisplay?.vertical.kbitsPerSec ?? 0).toFixed(0), + }; + } + get streamQuality() { if ( this.state.percentageDroppedFrames > 50 || @@ -127,6 +139,7 @@ export class PerformanceService extends StatefulService { numberEncodedFrames: 0, numberRenderedFrames: 0, streamingBandwidth: 0, + streamingBandwidthByDisplay: createEmptyDisplayStats(), frameRate: 0, }; @@ -203,6 +216,7 @@ export class PerformanceService extends StatefulService { numberDroppedFrames: streamingStats.droppedFrames, percentageDroppedFrames, streamingBandwidth: streamingStats.kbitsPerSec, + streamingBandwidthByDisplay: streamingStats.byDisplay ?? createEmptyDisplayStats(), // averageTimeToRenderFrame: obs.Global.averageFrameRenderTime, // diskSpaceAvailable: obs.Global.diskSpaceAvailable, // memoryUsage: obs.Global.memoryUsage, diff --git a/app/services/streaming/streaming-statistics.ts b/app/services/streaming/streaming-statistics.ts new file mode 100644 index 000000000000..558db9df82da --- /dev/null +++ b/app/services/streaming/streaming-statistics.ts @@ -0,0 +1,88 @@ +export type TStreamingPerformanceStatsDisplay = 'horizontal' | 'vertical'; + +export interface IStreamingPerformanceDisplayStats { + kbitsPerSec: number; + dataOutput: number; +} + +export type TStreamingPerformanceDisplayStatsByDisplay = Record< + TStreamingPerformanceStatsDisplay, + IStreamingPerformanceDisplayStats +>; + +export interface IStreamingPerformanceStatsInstance { + display?: TStreamingPerformanceStatsDisplay; + displayStats?: TStreamingPerformanceDisplayStatsByDisplay; + droppedFrames: number; + totalFrames: number; + kbitsPerSec: number; + dataOutput: number; +} + +export interface IStreamingPerformanceStats { + droppedFrames: number; + totalFrames: number; + kbitsPerSec: number; + dataOutput: number; + byDisplay?: TStreamingPerformanceDisplayStatsByDisplay; +} + +export interface IStreamingPerformanceStatsOptions { + calculateByDisplay?: boolean; +} + +export function createEmptyDisplayStats(): TStreamingPerformanceDisplayStatsByDisplay { + return { + horizontal: { kbitsPerSec: 0, dataOutput: 0 }, + vertical: { kbitsPerSec: 0, dataOutput: 0 }, + }; +} + +function addDisplayStats( + target: TStreamingPerformanceDisplayStatsByDisplay, + display: TStreamingPerformanceStatsDisplay, + stats: IStreamingPerformanceDisplayStats, +) { + target[display].kbitsPerSec += stats.kbitsPerSec; + target[display].dataOutput += stats.dataOutput; +} + +export function calculateStreamingPerformanceStats( + instances: IStreamingPerformanceStatsInstance[], + options: IStreamingPerformanceStatsOptions = {}, +): IStreamingPerformanceStats { + let droppedFrames = 0; + let totalFrames = 0; + let kbitsPerSec = 0; + let dataOutput = 0; + const byDisplay = options.calculateByDisplay ? createEmptyDisplayStats() : undefined; + + instances.forEach(instance => { + droppedFrames += instance.droppedFrames; + totalFrames += instance.totalFrames; + kbitsPerSec += instance.kbitsPerSec; + dataOutput += instance.dataOutput; + + if (byDisplay && instance.displayStats) { + addDisplayStats(byDisplay, 'horizontal', instance.displayStats.horizontal); + addDisplayStats(byDisplay, 'vertical', instance.displayStats.vertical); + } else if (byDisplay && instance.display) { + addDisplayStats(byDisplay, instance.display, { + kbitsPerSec: instance.kbitsPerSec, + dataOutput: instance.dataOutput, + }); + } + }); + + if (instances.length > 1) { + kbitsPerSec = Math.round(kbitsPerSec / instances.length); + } + + return { + droppedFrames, + totalFrames, + kbitsPerSec, + dataOutput, + ...(byDisplay ? { byDisplay } : {}), + }; +} diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index a3ae26e74dc5..ff6cd29dc21d 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -95,6 +95,11 @@ import { EOBSOutputType, EOBSOutputSignal, IOBSOutputSignalInfo } from 'services import { SignalsService } from 'services/signals-manager'; import { TSocketEvent } from 'services/websocket'; import { HighlighterService } from 'services/highlighter'; +import { + calculateStreamingPerformanceStats, + TStreamingPerformanceDisplayStatsByDisplay, + IStreamingPerformanceStatsInstance, +} from './streaming-statistics'; type TOBSOutputType = 'streaming' | 'recording' | 'replayBuffer'; type TOutputContext = TDisplayType | 'enhancedBroadcasting' | 'stream' | 'streamSecond'; @@ -188,7 +193,6 @@ export class StreamingService streamingStateChange = new Subject(); powerSaveId: number; - private numInstances: number = 0; private resolveStartStreaming: Function = () => {}; private rejectStartStreaming: Function = () => {}; @@ -2662,9 +2666,6 @@ export class StreamingService } else { await this.handleStartSingleOutputStream(info.signal, context, nextState, time); } - // Memoize number of streaming instances for performance metrics calculation - this.numInstances++; - // Updating state for the UI is handled in the above functions return; } else if (info.signal === EOBSOutputSignal.Activate) { @@ -3271,6 +3272,14 @@ export class StreamingService return 'additionalVideo' in instance; } + private getEnhancedBroadcastingDisplayStats( + instance: IEnhancedBroadcastingSimpleStreaming | IEnhancedBroadcastingAdvancedStreaming, + ): TStreamingPerformanceDisplayStatsByDisplay | undefined { + return ((instance as unknown) as { + displayStats?: TStreamingPerformanceDisplayStatsByDisplay; + }).displayStats; + } + getStreamingInstance(): ISimpleStreaming | IAdvancedStreaming | null { return ( this.contexts.horizontal?.streaming ?? @@ -3514,38 +3523,30 @@ export class StreamingService * PERFORMANCE STATISTICS */ get streamingPerformanceStats() { - let droppedFrames = 0; - let totalFrames = 0; - let kbitsPerSec = 0; - let dataOutput = 0; + const isDualOutputMode = this.views.isDualOutputMode; + const instances: IStreamingPerformanceStatsInstance[] = []; for (const contextName of Object.keys(this.contexts) as TOutputContext[]) { const instance = this.contexts[contextName].streaming; if (!instance) continue; - // Twitch enhanced broadcasting and dual format encodes three additional resolutions on the frontend - // so we need to average these for accurate display bitrate. Note: Enhanced broadcasting will always be - // around 4500 kbitsPerSec bitrate requirements for the resolutions. - if (this.isEnhancedBroadcastingStreaming(instance)) { - droppedFrames += instance.droppedFrames; - totalFrames += instance.totalFrames; - kbitsPerSec += Math.round(instance.kbitsPerSec / 3); - dataOutput += instance.dataOutput; - } else { - droppedFrames += instance.droppedFrames; - totalFrames += instance.totalFrames; - kbitsPerSec += instance.kbitsPerSec; - dataOutput += instance.dataOutput; - } - } - - // TODO: Add UI to show bitrate by display but for now average the all instances, which is more accurate - // than only showing the horizontal display's bitrate in dual output mode + const display = + isDualOutputMode && this.isDisplayContext(contextName) ? contextName : undefined; + const displayStats = + isDualOutputMode && this.isEnhancedBroadcastingStreaming(instance) + ? this.getEnhancedBroadcastingDisplayStats(instance) + : undefined; - if (this.numInstances > 1) { - kbitsPerSec = Math.round(kbitsPerSec / this.numInstances); + instances.push({ + display, + displayStats, + droppedFrames: instance.droppedFrames, + totalFrames: instance.totalFrames, + kbitsPerSec: instance.kbitsPerSec, + dataOutput: instance.dataOutput, + }); } - return { droppedFrames, totalFrames, kbitsPerSec, dataOutput }; + return calculateStreamingPerformanceStats(instances, { calculateByDisplay: isDualOutputMode }); } get recordingPerformanceStats() { @@ -4001,8 +4002,6 @@ export class StreamingService this.contexts[contextName].streaming?.stop(true); } - - this.numInstances = 0; } /** diff --git a/scripts/repositories.json b/scripts/repositories.json index 74596505e8cd..bf52d73b5eeb 100644 --- a/scripts/repositories.json +++ b/scripts/repositories.json @@ -4,7 +4,7 @@ "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.26.29b3", + "version": "per-display-bitrate-stats-1", "win64": true, "osx": true }, diff --git a/test/regular/components-react/performance-metrics.ts b/test/regular/components-react/performance-metrics.ts new file mode 100644 index 000000000000..a95928bd496f --- /dev/null +++ b/test/regular/components-react/performance-metrics.ts @@ -0,0 +1,56 @@ +import test from 'ava'; +import * as Module from 'module'; + +const moduleLoader = Module as any; +const originalLoad = moduleLoader._load; + +moduleLoader._load = function (this: unknown, request: string, ...args: unknown[]) { + if (request === 'services/i18n') return { $t: (key: string) => key }; + return originalLoad.call(this, request, ...args); +}; + +const { getPerformanceMetricMetadata } = require('../../../app/components-react/shared/performance-metrics') as typeof import('../../../app/components-react/shared/performance-metrics'); + +moduleLoader._load = originalLoad; + +test('Performance metric metadata uses one combined bandwidth metric outside dual output mode', t => { + const metrics = getPerformanceMetricMetadata({ + cpuPercent: '8.1', + frameRate: '30.00', + droppedFrames: 124, + percentDropped: '1.9', + bandwidth: '8290', + bandwidthByDisplay: { horizontal: '5000', vertical: '3290' }, + isDualOutputMode: false, + }); + + t.deepEqual( + metrics.filter(metric => metric.attribute === 'bandwidth'), + [{ key: 'bandwidth', attribute: 'bandwidth', value: '8290', label: 'kb/s', icon: 'icon-bitrate' }], + ); +}); + +test('Performance metric metadata shows horizontal and vertical bandwidth in dual output mode', t => { + const metrics = getPerformanceMetricMetadata({ + cpuPercent: '8.1', + frameRate: '30.00', + droppedFrames: 124, + percentDropped: '1.9', + bandwidth: '8290', + bandwidthByDisplay: { horizontal: '5000', vertical: '3290' }, + isDualOutputMode: true, + }); + + t.deepEqual( + metrics.filter(metric => metric.attribute === 'bandwidth'), + [ + { + key: 'bandwidth', + attribute: 'bandwidth', + value: 'H: 5000 V: 3290', + label: 'kb/s', + icon: 'icon-bitrate', + }, + ], + ); +}); diff --git a/test/regular/platform-apps-source-url.ts b/test/regular/platform-apps-source-url.ts index 170468dc6ad3..3406d819f46c 100644 --- a/test/regular/platform-apps-source-url.ts +++ b/test/regular/platform-apps-source-url.ts @@ -1,7 +1,7 @@ import test from 'ava'; import { stringifyAppSourceSettings } from '../../app/services/platform-apps/source-url'; -test('stringifyAppSourceSettings serializes object settings as JSON', t => { +test('StringifyAppSourceSettings serializes object settings as JSON', t => { const settings = { LONG_ACCESSTOKEN: 'token', intervals: '15', @@ -13,19 +13,19 @@ test('stringifyAppSourceSettings serializes object settings as JSON', t => { ); }); -test('stringifyAppSourceSettings preserves string settings', t => { +test('StringifyAppSourceSettings preserves string settings', t => { const settings = '{"LONG_ACCESSTOKEN":"token","intervals":"15"}'; t.is(stringifyAppSourceSettings(settings), settings); }); -test('stringifyAppSourceSettings returns empty string for empty settings', t => { +test('StringifyAppSourceSettings returns empty string for empty settings', t => { t.is(stringifyAppSourceSettings(undefined), ''); t.is(stringifyAppSourceSettings(null), ''); t.is(stringifyAppSourceSettings(''), ''); }); -test('stringifyAppSourceSettings returns empty string for non-serializable settings', t => { +test('StringifyAppSourceSettings returns empty string for non-serializable settings', t => { const settings: Record = {}; settings.self = settings; diff --git a/test/regular/streaming/streaming-statistics.ts b/test/regular/streaming/streaming-statistics.ts new file mode 100644 index 000000000000..b54eebbd894d --- /dev/null +++ b/test/regular/streaming/streaming-statistics.ts @@ -0,0 +1,104 @@ +import test from 'ava'; +import { calculateStreamingPerformanceStats } from '../../../app/services/streaming/streaming-statistics'; + +test('Streaming stats use the current live instance count for bitrate averaging', t => { + const stats = calculateStreamingPerformanceStats([ + { + droppedFrames: 0, + totalFrames: 300, + kbitsPerSec: 6000, + dataOutput: 1024, + }, + ]); + + t.is(stats.kbitsPerSec, 6000); +}); + +test('Streaming stats average bitrate across active regular streaming instances without display stats', t => { + const stats = calculateStreamingPerformanceStats([ + { + display: 'horizontal', + droppedFrames: 1, + totalFrames: 100, + kbitsPerSec: 6000, + dataOutput: 1024, + }, + { + display: 'vertical', + droppedFrames: 2, + totalFrames: 200, + kbitsPerSec: 4000, + dataOutput: 2048, + }, + ]); + + t.deepEqual(stats, { + droppedFrames: 3, + totalFrames: 300, + kbitsPerSec: 5000, + dataOutput: 3072, + }); +}); + +test('Streaming stats keep enhanced broadcasting aggregate bitrate', t => { + const stats = calculateStreamingPerformanceStats([ + { + droppedFrames: 0, + totalFrames: 300, + kbitsPerSec: 13500, + dataOutput: 1024, + }, + ]); + + t.is(stats.kbitsPerSec, 13500); +}); + +test('Streaming stats report regular streaming bitrate by display', t => { + const stats = calculateStreamingPerformanceStats( + [ + { + display: 'horizontal', + droppedFrames: 1, + totalFrames: 100, + kbitsPerSec: 6000, + dataOutput: 1024, + }, + { + display: 'vertical', + droppedFrames: 2, + totalFrames: 200, + kbitsPerSec: 4000, + dataOutput: 2048, + }, + ], + { calculateByDisplay: true }, + ); + + t.deepEqual(stats.byDisplay, { + horizontal: { kbitsPerSec: 6000, dataOutput: 1024 }, + vertical: { kbitsPerSec: 4000, dataOutput: 2048 }, + }); +}); + +test('Streaming stats report enhanced broadcasting bitrate by display', t => { + const stats = calculateStreamingPerformanceStats( + [ + { + droppedFrames: 0, + totalFrames: 300, + kbitsPerSec: 13500, + dataOutput: 4096, + displayStats: { + horizontal: { kbitsPerSec: 5000, dataOutput: 1500 }, + vertical: { kbitsPerSec: 2500, dataOutput: 700 }, + }, + }, + ], + { calculateByDisplay: true }, + ); + + t.deepEqual(stats.byDisplay, { + horizontal: { kbitsPerSec: 5000, dataOutput: 1500 }, + vertical: { kbitsPerSec: 2500, dataOutput: 700 }, + }); +});