From cf72bc5d6198348736f347a97aebdb6b439cd8b2 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Tue, 4 Mar 2025 12:09:30 -0500 Subject: [PATCH 01/25] Patch api migration from dual output recording. --- app/services/recording-mode.ts | 17 +- .../settings/output/output-settings.ts | 125 +++- app/services/streaming/streaming-api.ts | 4 + app/services/streaming/streaming.ts | 551 ++++++++++++++++-- 4 files changed, 644 insertions(+), 53 deletions(-) diff --git a/app/services/recording-mode.ts b/app/services/recording-mode.ts index fc605b727dc8..e687521dde55 100644 --- a/app/services/recording-mode.ts +++ b/app/services/recording-mode.ts @@ -8,7 +8,7 @@ import { ELayout, ELayoutElement, LayoutService } from './layout'; import { ScenesService } from './scenes'; import { EObsSimpleEncoder, SettingsService } from './settings'; import { AnchorPoint, ScalableRectangle } from 'util/ScalableRectangle'; -import { VideoSettingsService } from './settings-v2/video'; +import { TDisplayType, VideoSettingsService } from './settings-v2/video'; import { ENotificationType, NotificationsService } from 'services/notifications'; import { DefaultHardwareService } from './hardware'; import { RunInLoadingMode } from './app/app-decorators'; @@ -18,6 +18,7 @@ import { NavigationService, UsageStatisticsService, SharedStorageService } from import { getPlatformService } from 'services/platforms'; import { IYoutubeUploadResponse } from 'services/platforms/youtube/uploader'; import { YoutubeService } from 'services/platforms/youtube'; +import { capitalize } from 'lodash'; export interface IRecordingEntry { timestamp: string; @@ -180,12 +181,22 @@ export class RecordingModeService extends PersistentStatefulService<IRecordingMo }, 10 * 1000); } - addRecordingEntry(filename: string) { + /** + * Add entry to recording history and show notification + * @param filename - name of file to show in recording history + * @param showNotification - primarily used when recording in dual output mode to only show the notification once + */ + addRecordingEntry(filename: string, display?: TDisplayType) { const timestamp = moment().format(); this.ADD_RECORDING_ENTRY(timestamp, filename); + + const message = display + ? $t(`A new ${capitalize(display)} Recording has been completed. Click for more info`) + : $t('A new Recording has been completed. Click for more info'); + this.notificationsService.actions.push({ type: ENotificationType.SUCCESS, - message: $t('A new Recording has been completed. Click for more info'), + message, action: this.jsonrpcService.createRequest( Service.getResourceId(this), 'showRecordingHistory', diff --git a/app/services/settings/output/output-settings.ts b/app/services/settings/output/output-settings.ts index 1b446463e78a..517cb19db660 100644 --- a/app/services/settings/output/output-settings.ts +++ b/app/services/settings/output/output-settings.ts @@ -5,7 +5,7 @@ import { HighlighterService } from 'services/highlighter'; import { Inject } from 'services/core/injector'; import { Dictionary } from 'vuex'; import { AudioService } from 'app-services'; -import { parse } from 'path'; +import { ERecordingQuality, ERecordingFormat } from 'obs-studio-node'; /** * list of encoders for simple mode @@ -230,6 +230,110 @@ export class OutputSettingsService extends Service { }; } + getSimpleRecordingSettings() { + const output = this.settingsService.state.Output.formData; + const advanced = this.settingsService.state.Advanced.formData; + + const path: string = this.settingsService.findSettingValue(output, 'Recording', 'FilePath'); + + const format: ERecordingFormat = this.settingsService.findValidListValue( + output, + 'Recording', + 'RecFormat', + ) as ERecordingFormat; + + const oldQualityName = this.settingsService.findSettingValue(output, 'Recording', 'RecQuality'); + let quality: ERecordingQuality = ERecordingQuality.HigherQuality; + switch (oldQualityName) { + case 'Small': + quality = ERecordingQuality.HighQuality; + break; + case 'HQ': + quality = ERecordingQuality.HigherQuality; + break; + case 'Lossless': + quality = ERecordingQuality.Lossless; + break; + case 'Stream': + quality = ERecordingQuality.Stream; + break; + } + + const convertedEncoderName: + | EObsSimpleEncoder.x264_lowcpu + | EObsAdvancedEncoder = this.convertEncoderToNewAPI(this.getSettings().recording.encoder); + + const encoder: EObsAdvancedEncoder = + convertedEncoderName === EObsSimpleEncoder.x264_lowcpu + ? EObsAdvancedEncoder.obs_x264 + : convertedEncoderName; + + const lowCPU: boolean = convertedEncoderName === EObsSimpleEncoder.x264_lowcpu; + + const overwrite: boolean = this.settingsService.findSettingValue( + advanced, + 'Recording', + 'OverwriteIfExists', + ); + + const noSpace: boolean = this.settingsService.findSettingValue( + output, + 'Recording', + 'FileNameWithoutSpace', + ); + + return { + path, + format, + quality, + encoder, + lowCPU, + overwrite, + noSpace, + }; + } + + getAdvancedRecordingSettings() { + const output = this.settingsService.state.Output.formData; + const advanced = this.settingsService.state.Advanced.formData; + + const path = this.settingsService.findSettingValue(output, 'Recording', 'RecFilePath'); + const encoder = this.settingsService.findSettingValue(output, 'Recording', 'RecEncoder'); + const rescaling = this.settingsService.findSettingValue(output, 'Recording', 'RecRescale'); + const mixer = this.settingsService.findSettingValue(output, 'Recording', 'RecTracks'); + const useStreamEncoders = + this.settingsService.findSettingValue(output, 'Recording', 'RecEncoder') === 'none'; + + const format = this.settingsService.findValidListValue( + output, + 'Recording', + 'RecFormat', + ) as ERecordingFormat; + + const overwrite = this.settingsService.findSettingValue( + advanced, + 'Recording', + 'OverwriteIfExists', + ); + + const noSpace = this.settingsService.findSettingValue( + output, + 'Recording', + 'RecFileNameWithoutSpace', + ); + + return { + path, + format, + encoder, + overwrite, + noSpace, + rescaling, + mixer, + useStreamEncoders, + }; + } + private getStreamingEncoderSettings( output: ISettingsSubCategory[], video: ISettingsSubCategory[], @@ -484,4 +588,23 @@ export class OutputSettingsService extends Service { this.settingsService.setSettingValue('Output', 'Recbitrate', settingsPatch.bitrate); } } + + convertEncoderToNewAPI( + encoder: EObsSimpleEncoder | string, + ): EObsSimpleEncoder.x264_lowcpu | EObsAdvancedEncoder { + switch (encoder) { + case EObsSimpleEncoder.x264: + return EObsAdvancedEncoder.obs_x264; + case EObsSimpleEncoder.nvenc: + return EObsAdvancedEncoder.ffmpeg_nvenc; + case EObsSimpleEncoder.amd: + return EObsAdvancedEncoder.amd_amf_h264; + case EObsSimpleEncoder.qsv: + return EObsAdvancedEncoder.obs_qsv11; + case EObsSimpleEncoder.jim_nvenc: + return EObsAdvancedEncoder.jim_nvenc; + case EObsSimpleEncoder.x264_lowcpu: + return EObsSimpleEncoder.x264_lowcpu; + } + } } diff --git a/app/services/streaming/streaming-api.ts b/app/services/streaming/streaming-api.ts index 9baec256eb06..a03010d67696 100644 --- a/app/services/streaming/streaming-api.ts +++ b/app/services/streaming/streaming-api.ts @@ -97,9 +97,13 @@ export interface IPlatformFlags { export interface IStreamingServiceState { streamingStatus: EStreamingState; + verticalStreamingStatus: EStreamingState; streamingStatusTime: string; + verticalStreamingStatusTime: string; recordingStatus: ERecordingState; + verticalRecordingStatus: ERecordingState; recordingStatusTime: string; + verticalRecordingStatusTime: string; replayBufferStatus: EReplayBufferState; replayBufferStatusTime: string; selectiveRecording: boolean; diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index eebc89536b12..b2020bd600e0 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -1,6 +1,18 @@ import Vue from 'vue'; import { mutation, StatefulService } from 'services/core/stateful-service'; -import { EOutputCode, Global, NodeObs } from '../../../obs-api'; +import { + AdvancedRecordingFactory, + AudioEncoderFactory, + AudioTrackFactory, + EOutputCode, + Global, + IAdvancedRecording, + ISimpleRecording, + NodeObs, + SimpleRecordingFactory, + VideoEncoderFactory, + EOutputSignal, +} from '../../../obs-api'; import { Inject } from 'services/core/injector'; import moment from 'moment'; import padStart from 'lodash/padStart'; @@ -55,7 +67,6 @@ import { MarkersService } from 'services/markers'; import { byOS, OS } from 'util/operating-systems'; import { DualOutputService } from 'services/dual-output'; import { capitalize } from 'lodash'; -import { TikTokService } from 'services/platforms/tiktok'; import { YoutubeService } from 'app-services'; enum EOBSOutputType { @@ -76,6 +87,12 @@ enum EOBSOutputSignal { WriteError = 'writing_error', } +enum EOutputSignalState { + Start = 'start', + Stop = 'stop', + Stopping = 'stopping', + Wrote = 'wrote', +} export interface IOBSOutputSignalInfo { type: EOBSOutputType; signal: EOBSOutputSignal; @@ -101,7 +118,6 @@ export class StreamingService @Inject() private videoSettingsService: VideoSettingsService; @Inject() private markersService: MarkersService; @Inject() private dualOutputService: DualOutputService; - @Inject() private tikTokService: TikTokService; @Inject() private youtubeService: YoutubeService; streamingStatusChange = new Subject<EStreamingState>(); @@ -115,17 +131,25 @@ export class StreamingService // Dummy subscription for stream deck streamingStateChange = new Subject<void>(); + private recordingStopped = new Subject(); powerSaveId: number; private resolveStartStreaming: Function = () => {}; private rejectStartStreaming: Function = () => {}; + private horizontalRecording: ISimpleRecording | IAdvancedRecording = null; + private verticalRecording: ISimpleRecording | IAdvancedRecording = null; + static initialState: IStreamingServiceState = { streamingStatus: EStreamingState.Offline, + verticalStreamingStatus: EStreamingState.Offline, streamingStatusTime: new Date().toISOString(), + verticalStreamingStatusTime: new Date().toISOString(), recordingStatus: ERecordingState.Offline, + verticalRecordingStatus: ERecordingState.Offline, recordingStatusTime: new Date().toISOString(), + verticalRecordingStatusTime: new Date().toString(), replayBufferStatus: EReplayBufferState.Offline, replayBufferStatusTime: new Date().toISOString(), selectiveRecording: false, @@ -155,7 +179,7 @@ export class StreamingService init() { NodeObs.OBS_service_connectOutputSignals((info: IOBSOutputSignalInfo) => { this.signalInfoChanged.next(info); - this.handleOBSOutputSignal(info); + this.handleOBSV2OutputSignal(info); }); // watch for StreamInfoView at emit `streamInfoChanged` event if something has been hanged there @@ -214,12 +238,12 @@ export class StreamingService } catch (e: unknown) { // cast all PLATFORM_REQUEST_FAILED errors to PREPOPULATE_FAILED if (e instanceof StreamError) { - const type = + e.type = (e.type as TStreamErrorType) === 'PLATFORM_REQUEST_FAILED' ? 'PREPOPULATE_FAILED' : e.type || 'UNKNOWN_ERROR'; - const error = this.handleTypedStreamError(e, type, `Failed to prepopulate ${platform}`); - this.setError(error, platform); + + this.setError(e, platform); } else { this.setError('PREPOPULATE_FAILED', platform); } @@ -262,34 +286,6 @@ export class StreamingService return false; } - /** - * set platform stream settings - */ - async handleSetupPlatform( - platform: TPlatform, - settings: IGoLiveSettings, - unattendedMode: boolean, - assignContext: boolean = false, - ) { - const service = getPlatformService(platform); - try { - // don't update settings for twitch in unattendedMode - const settingsForPlatform = - !assignContext && platform === 'twitch' && unattendedMode ? undefined : settings; - - if (assignContext) { - const display = settings.platforms[platform]?.display; - await this.runCheck(platform, () => service.beforeGoLive(settingsForPlatform, display)); - } else { - await this.runCheck(platform, () => - service.beforeGoLive(settingsForPlatform, 'horizontal'), - ); - } - } catch (e: unknown) { - this.handleSetupPlatformError(e, platform); - } - } - /** * Make a transition to Live */ @@ -928,10 +924,11 @@ export class StreamingService this.powerSaveId = remote.powerSaveBlocker.start('prevent-display-sleep'); - // start streaming + // handle start streaming and recording if (this.views.isDualOutputMode) { // start dual output + // stream horizontal and stream vertical const horizontalContext = this.videoSettingsService.contexts.horizontal; const verticalContext = this.videoSettingsService.contexts.vertical; @@ -943,10 +940,21 @@ export class StreamingService if (signalInfo.code !== 0) { NodeObs.OBS_service_stopStreaming(true, 'horizontal'); NodeObs.OBS_service_stopStreaming(true, 'vertical'); + // Refactor when move streaming to new API + if (this.state.verticalStreamingStatus !== EStreamingState.Offline) { + this.SET_VERTICAL_STREAMING_STATUS(EStreamingState.Offline); + } } if (signalInfo.signal === EOBSOutputSignal.Start) { NodeObs.OBS_service_startStreaming('vertical'); + + // Refactor when move streaming to new API + const time = new Date().toISOString(); + if (this.state.verticalStreamingStatus === EStreamingState.Offline) { + this.SET_VERTICAL_STREAMING_STATUS(EStreamingState.Live, time); + } + signalChanged.unsubscribe(); } } @@ -961,12 +969,12 @@ export class StreamingService NodeObs.OBS_service_setVideoInfo(horizontalContext, 'horizontal'); NodeObs.OBS_service_startStreaming(); - } - const recordWhenStreaming = this.streamSettingsService.settings.recordWhenStreaming; + const recordWhenStreaming = this.streamSettingsService.settings.recordWhenStreaming; - if (recordWhenStreaming && this.state.recordingStatus === ERecordingState.Offline) { - this.toggleRecording(); + if (recordWhenStreaming && this.state.recordingStatus === ERecordingState.Offline) { + this.toggleRecording(); + } } const replayWhenStreaming = this.streamSettingsService.settings.replayBufferWhileStreaming; @@ -1050,6 +1058,10 @@ export class StreamingService signalInfo.signal === EOBSOutputSignal.Deactivate ) { NodeObs.OBS_service_stopStreaming(false, 'vertical'); + // Refactor when move streaming to new API + if (this.state.verticalStreamingStatus !== EStreamingState.Offline) { + this.SET_VERTICAL_STREAMING_STATUS(EStreamingState.Offline); + } signalChanged.unsubscribe(); } }, @@ -1063,7 +1075,10 @@ export class StreamingService } const keepRecording = this.streamSettingsService.settings.keepRecordingWhenStreamStops; - if (!keepRecording && this.state.recordingStatus === ERecordingState.Recording) { + const isRecording = + this.state.recordingStatus === ERecordingState.Recording || + this.state.verticalRecordingStatus === ERecordingState.Recording; + if (!keepRecording && isRecording) { this.toggleRecording(); } @@ -1082,8 +1097,15 @@ export class StreamingService } if (this.state.streamingStatus === EStreamingState.Ending) { - if (this.views.isDualOutputMode) { + if ( + this.views.isDualOutputMode && + this.state.verticalRecordingStatus === ERecordingState.Offline + ) { NodeObs.OBS_service_stopStreaming(true, 'horizontal'); + // Refactor when move streaming to new API + if (this.state.verticalStreamingStatus !== EStreamingState.Offline) { + this.SET_VERTICAL_STREAMING_STATUS(EStreamingState.Offline); + } NodeObs.OBS_service_stopStreaming(true, 'vertical'); } else { NodeObs.OBS_service_stopStreaming(true); @@ -1107,15 +1129,207 @@ export class StreamingService } toggleRecording() { - if (this.state.recordingStatus === ERecordingState.Recording) { - NodeObs.OBS_service_stopRecording(); + // stop recording + if ( + this.state.recordingStatus === ERecordingState.Recording && + this.state.verticalRecordingStatus === ERecordingState.Recording + ) { + // stop recroding both displays + let time = new Date().toISOString(); + + if (this.verticalRecording !== null) { + const recordingStopped = this.recordingStopped.subscribe(async () => { + await new Promise(resolve => + // sleep for 2 seconds to allow a different time stamp to be generated + // because the recording history uses the time stamp as keys + // if the same time stamp is used, the entry will be replaced in the recording history + setTimeout(() => { + time = new Date().toISOString(); + this.SET_RECORDING_STATUS(ERecordingState.Stopping, time, 'horizontal'); + if (this.horizontalRecording !== null) { + this.horizontalRecording.stop(); + } + }, 2000), + ); + recordingStopped.unsubscribe(); + }); + + this.SET_RECORDING_STATUS(ERecordingState.Stopping, time, 'vertical'); + this.verticalRecording.stop(); + this.recordingStopped.next(); + } + return; + } else if ( + this.state.verticalRecordingStatus === ERecordingState.Recording && + this.verticalRecording !== null + ) { + // stop recording vertical display + const time = new Date().toISOString(); + this.SET_RECORDING_STATUS(ERecordingState.Stopping, time, 'vertical'); + this.verticalRecording.stop(); + } else if ( + this.state.recordingStatus === ERecordingState.Recording && + this.horizontalRecording !== null + ) { + const time = new Date().toISOString(); + // stop recording horizontal display + this.SET_RECORDING_STATUS(ERecordingState.Stopping, time, 'horizontal'); + this.horizontalRecording.stop(); + } + + // start recording + if ( + this.state.recordingStatus === ERecordingState.Offline && + this.state.verticalRecordingStatus === ERecordingState.Offline + ) { + if (this.views.isDualOutputMode) { + if (this.state.streamingStatus !== EStreamingState.Offline) { + // In dual output mode, if the streaming status is starting then this call to toggle recording came from the function to toggle streaming. + // In this case, only stream the horizontal display (don't record the horizontal display) and record the vertical display. + this.createRecording('vertical', 2); + } else { + // Otherwise, record both displays in dual output mode + this.createRecording('vertical', 2); + this.createRecording('horizontal', 1); + } + } else { + // In single output mode, recording only the horizontal display + this.createRecording('horizontal', 1); + } + } + } + + private createRecording(display: TDisplayType, index: number) { + const mode = this.outputSettingsService.getSettings().mode; + + const recording = + mode === 'Advanced' + ? (AdvancedRecordingFactory.create() as IAdvancedRecording) + : (SimpleRecordingFactory.create() as ISimpleRecording); + + const settings = + mode === 'Advanced' + ? this.outputSettingsService.getAdvancedRecordingSettings() + : this.outputSettingsService.getSimpleRecordingSettings(); + + // assign settings + Object.keys(settings).forEach(key => { + if (key === 'encoder') { + recording.videoEncoder = VideoEncoderFactory.create(settings.encoder, 'video-encoder'); + } else { + (recording as any)[key] = (settings as any)[key]; + } + }); + + // assign context + recording.video = this.videoSettingsService.contexts[display]; + + // set signal handler + recording.signalHandler = async signal => { + await this.handleRecordingSignal(signal, display); + }; + + // handle unique properties (including audio) + if (mode === 'Advanced') { + (recording as IAdvancedRecording).outputWidth = this.dualOutputService.views.videoSettings[ + display + ].outputWidth; + (recording as IAdvancedRecording).outputHeight = this.dualOutputService.views.videoSettings[ + display + ].outputHeight; + + const trackName = `track${index}`; + const track = AudioTrackFactory.create(160, trackName); + AudioTrackFactory.setAtIndex(track, index); + } else { + (recording as ISimpleRecording).audioEncoder = AudioEncoderFactory.create(); + } + + // save in state + if (display === 'vertical') { + this.verticalRecording = recording; + this.verticalRecording.start(); + } else { + this.horizontalRecording = recording; + this.horizontalRecording.start(); + } + } + + private async handleRecordingSignal(info: EOutputSignal, display: TDisplayType) { + // map signals to status + const nextState: ERecordingState = ({ + [EOutputSignalState.Start]: ERecordingState.Recording, + [EOutputSignalState.Stop]: ERecordingState.Offline, + [EOutputSignalState.Stopping]: ERecordingState.Stopping, + [EOutputSignalState.Wrote]: ERecordingState.Wrote, + } as Dictionary<ERecordingState>)[info.signal]; + + // We received a signal we didn't recognize + if (!nextState) return; + + if (nextState === ERecordingState.Recording) { + const mode = this.views.isDualOutputMode ? 'dual' : 'single'; + this.usageStatisticsService.recordFeatureUsage('Recording'); + this.usageStatisticsService.recordAnalyticsEvent('RecordingStatus', { + status: ERecordingState.Recording, + code: info.code, + mode, + display, + }); } - if (this.state.recordingStatus === ERecordingState.Offline) { - NodeObs.OBS_service_startRecording(); + if (nextState === ERecordingState.Wrote) { + const fileName = + display === 'vertical' + ? this.verticalRecording.lastFile() + : this.horizontalRecording.lastFile(); + + const parsedName = byOS({ + [OS.Mac]: fileName, + [OS.Windows]: fileName.replace(/\//, '\\'), + }); + + // in dual output mode, each confirmation should be labelled for each display + if (this.views.isDualOutputMode) { + this.recordingModeService.addRecordingEntry(parsedName, display); + } else { + this.recordingModeService.addRecordingEntry(parsedName); + } + await this.markersService.exportCsv(parsedName); + + // destroy recording factory instances + if (this.outputSettingsService.getSettings().mode === 'Advanced') { + if (display === 'horizontal' && this.horizontalRecording) { + AdvancedRecordingFactory.destroy(this.horizontalRecording as IAdvancedRecording); + this.horizontalRecording = null; + } else if (display === 'vertical' && this.verticalRecording) { + AdvancedRecordingFactory.destroy(this.verticalRecording as IAdvancedRecording); + this.verticalRecording = null; + } + } else { + if (display === 'horizontal' && this.horizontalRecording) { + SimpleRecordingFactory.destroy(this.horizontalRecording as ISimpleRecording); + this.horizontalRecording = null; + } else if (display === 'vertical' && this.verticalRecording) { + SimpleRecordingFactory.destroy(this.verticalRecording as ISimpleRecording); + this.verticalRecording = null; + } + } + + const time = new Date().toISOString(); + this.SET_RECORDING_STATUS(ERecordingState.Offline, time, display); + this.recordingStatusChange.next(ERecordingState.Offline); + + this.handleV2OutputCode(info); return; } + + const time = new Date().toISOString(); + this.SET_RECORDING_STATUS(nextState, time, display); + this.recordingStatusChange.next(nextState); + + this.handleV2OutputCode(info); } splitFile() { @@ -1225,6 +1439,13 @@ export class StreamingService } get formattedDurationInCurrentRecordingState() { + // in dual output mode, if using vertical recording as the second destination + // display the vertical recording status time + if (this.state.recordingStatus !== ERecordingState.Offline) { + this.formattedDurationSince(moment(this.state.recordingStatusTime)); + } else if (this.state.verticalRecordingStatus !== ERecordingState.Offline) { + return this.formattedDurationSince(moment(this.state.verticalRecordingStatusTime)); + } return this.formattedDurationSince(moment(this.state.recordingStatusTime)); } @@ -1272,6 +1493,223 @@ export class StreamingService private streamErrorUserMessage = ''; private streamErrorReportMessage = ''; + private handleOBSV2OutputSignal(info: IOBSOutputSignalInfo) { + console.debug('OBS Output signal: ', info); + + /* + * Resolve when: + * - Single output mode: always resolve + * - Dual output mode: after vertical stream started + * - Dual output mode: when vertical display is second destination, + * resolve after horizontal stream started + */ + const isVerticalDisplayStartSignal = + info.service === 'vertical' && info.signal === EOBSOutputSignal.Start; + + const shouldResolve = + !this.views.isDualOutputMode || + (this.views.isDualOutputMode && isVerticalDisplayStartSignal) || + (this.views.isDualOutputMode && info.signal === EOBSOutputSignal.Start); + + const time = new Date().toISOString(); + + if (info.type === EOBSOutputType.Streaming) { + if (info.signal === EOBSOutputSignal.Start && shouldResolve) { + this.SET_STREAMING_STATUS(EStreamingState.Live, time); + this.resolveStartStreaming(); + this.streamingStatusChange.next(EStreamingState.Live); + + let streamEncoderInfo: Partial<IOutputSettings> = {}; + let game: string = ''; + + try { + streamEncoderInfo = this.outputSettingsService.getSettings(); + game = this.views.game; + } catch (e: unknown) { + console.error('Error fetching stream encoder info: ', e); + } + + const eventMetadata: Dictionary<any> = { + ...streamEncoderInfo, + game, + }; + + if (this.videoEncodingOptimizationService.state.useOptimizedProfile) { + eventMetadata.useOptimizedProfile = true; + } + + const streamSettings = this.streamSettingsService.settings; + + eventMetadata.streamType = streamSettings.streamType; + eventMetadata.platform = streamSettings.platform; + eventMetadata.server = streamSettings.server; + + this.usageStatisticsService.recordEvent('stream_start', eventMetadata); + this.usageStatisticsService.recordAnalyticsEvent('StreamingStatus', { + code: info.code, + status: EStreamingState.Live, + service: streamSettings.service, + }); + this.usageStatisticsService.recordFeatureUsage('Streaming'); + } else if (info.signal === EOBSOutputSignal.Starting && shouldResolve) { + this.SET_STREAMING_STATUS(EStreamingState.Starting, time); + this.streamingStatusChange.next(EStreamingState.Starting); + } else if (info.signal === EOBSOutputSignal.Stop) { + this.SET_STREAMING_STATUS(EStreamingState.Offline, time); + this.RESET_STREAM_INFO(); + this.rejectStartStreaming(); + this.streamingStatusChange.next(EStreamingState.Offline); + this.usageStatisticsService.recordAnalyticsEvent('StreamingStatus', { + code: info.code, + status: EStreamingState.Offline, + }); + } else if (info.signal === EOBSOutputSignal.Stopping) { + this.sendStreamEndEvent(); + this.SET_STREAMING_STATUS(EStreamingState.Ending, time); + this.streamingStatusChange.next(EStreamingState.Ending); + } else if (info.signal === EOBSOutputSignal.Reconnect) { + this.SET_STREAMING_STATUS(EStreamingState.Reconnecting); + this.streamingStatusChange.next(EStreamingState.Reconnecting); + this.sendReconnectingNotification(); + } else if (info.signal === EOBSOutputSignal.ReconnectSuccess) { + this.SET_STREAMING_STATUS(EStreamingState.Live); + this.streamingStatusChange.next(EStreamingState.Live); + this.clearReconnectingNotification(); + } + } else if (info.type === EOBSOutputType.ReplayBuffer) { + const nextState: EReplayBufferState = ({ + [EOBSOutputSignal.Start]: EReplayBufferState.Running, + [EOBSOutputSignal.Stopping]: EReplayBufferState.Stopping, + [EOBSOutputSignal.Stop]: EReplayBufferState.Offline, + [EOBSOutputSignal.Wrote]: EReplayBufferState.Running, + [EOBSOutputSignal.WriteError]: EReplayBufferState.Running, + } as Dictionary<EReplayBufferState>)[info.signal]; + + if (nextState) { + this.SET_REPLAY_BUFFER_STATUS(nextState, time); + this.replayBufferStatusChange.next(nextState); + } + + if (info.signal === EOBSOutputSignal.Wrote) { + this.usageStatisticsService.recordAnalyticsEvent('ReplayBufferStatus', { + status: 'wrote', + code: info.code, + }); + this.replayBufferFileWrite.next(NodeObs.OBS_service_getLastReplay()); + } + } + this.handleV2OutputCode(info); + } + + private handleV2OutputCode(info: IOBSOutputSignalInfo | EOutputSignal) { + if (info.code) { + if (this.outputErrorOpen) { + console.warn('Not showing error message because existing window is open.', info); + return; + } + + let errorText = ''; + let extendedErrorText = ''; + let linkToDriverInfo = false; + let showNativeErrorMessage = false; + + if (info.code === EOutputCode.BadPath) { + errorText = $t( + 'Invalid Path or Connection URL. Please check your settings to confirm that they are valid.', + ); + } else if (info.code === EOutputCode.ConnectFailed) { + errorText = $t( + 'Failed to connect to the streaming server. Please check your internet connection.', + ); + } else if (info.code === EOutputCode.Disconnected) { + errorText = $t( + 'Disconnected from the streaming server. Please check your internet connection.', + ); + } else if (info.code === EOutputCode.InvalidStream) { + errorText = $t( + 'Could not access the specified channel or stream key. Please log out and back in to refresh your credentials. If the problem persists, there may be a problem connecting to the server.', + ); + } else if (info.code === EOutputCode.NoSpace) { + errorText = $t('There is not sufficient disk space to continue recording.'); + } else if (info.code === EOutputCode.Unsupported) { + errorText = + $t( + 'The output format is either unsupported or does not support more than one audio track. ', + ) + $t('Please check your settings and try again.'); + } else if (info.code === EOutputCode.OutdatedDriver) { + linkToDriverInfo = true; + errorText = $t( + 'An error occurred with the output. This is usually caused by out of date video drivers. Please ensure your Nvidia or AMD drivers are up to date and try again.', + ); + } else { + // -4 is used for generic unknown messages in OBS. Both -4 and any other code + // we don't recognize should fall into this branch and show a generic error. + errorText = $t( + 'An error occurred with the output. Please check your streaming and recording settings.', + ); + if (info.error) { + showNativeErrorMessage = true; + extendedErrorText = errorText + '\n\n' + $t('System error message:') + info.error + '"'; + } + } + const buttons = [$t('OK')]; + + const title = { + [EOBSOutputType.Streaming]: $t('Streaming Error'), + [EOBSOutputType.Recording]: $t('Recording Error'), + [EOBSOutputType.ReplayBuffer]: $t('Replay Buffer Error'), + }[info.type]; + + if (linkToDriverInfo) buttons.push($t('Learn More')); + if (showNativeErrorMessage) buttons.push($t('More')); + + this.outputErrorOpen = true; + const errorType = 'error'; + remote.dialog + .showMessageBox(Utils.getMainWindow(), { + buttons, + title, + type: errorType, + message: errorText, + }) + .then(({ response }) => { + if (linkToDriverInfo && response === 1) { + this.outputErrorOpen = false; + remote.shell.openExternal( + 'https://howto.streamlabs.com/streamlabs-obs-19/nvidia-graphics-driver-clean-install-tutorial-7000', + ); + } else { + let expectedResponse = 1; + if (linkToDriverInfo) { + expectedResponse = 2; + } + if (showNativeErrorMessage && response === expectedResponse) { + const buttons = [$t('OK')]; + remote.dialog + .showMessageBox({ + buttons, + title, + type: errorType, + message: extendedErrorText, + }) + .then(({ response }) => { + this.outputErrorOpen = false; + }) + .catch(() => { + this.outputErrorOpen = false; + }); + } else { + this.outputErrorOpen = false; + } + } + }) + .catch(() => { + this.outputErrorOpen = false; + }); + this.windowsService.actions.closeChildWindow(); + } + } + private handleOBSOutputSignal(info: IOBSOutputSignalInfo) { console.debug('OBS Output signal: ', info); @@ -1643,9 +2081,24 @@ export class StreamingService } @mutation() - private SET_RECORDING_STATUS(status: ERecordingState, time: string) { - this.state.recordingStatus = status; - this.state.recordingStatusTime = time; + private SET_VERTICAL_STREAMING_STATUS(status: EStreamingState, time?: string) { + this.state.streamingStatus = status; + if (time) this.state.streamingStatusTime = time; + } + + @mutation() + private SET_RECORDING_STATUS( + status: ERecordingState, + time: string, + display: TDisplayType = 'horizontal', + ) { + if (display === 'vertical') { + this.state.verticalRecordingStatus = status; + this.state.verticalRecordingStatusTime = time; + } else { + this.state.recordingStatus = status; + this.state.recordingStatusTime = time; + } } @mutation() From 7441051b1fe8c7cfc195ac31f8da4bf6662d45c1 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Tue, 4 Mar 2025 17:37:32 -0500 Subject: [PATCH 02/25] WIP: Migrate replay buffer. --- .../api/external-api/streaming/streaming.ts | 6 + app/services/streaming/streaming-api.ts | 2 + app/services/streaming/streaming.ts | 218 ++++++++++++++++-- 3 files changed, 209 insertions(+), 17 deletions(-) diff --git a/app/services/api/external-api/streaming/streaming.ts b/app/services/api/external-api/streaming/streaming.ts index b1d8623d67f8..42d2c33ee163 100644 --- a/app/services/api/external-api/streaming/streaming.ts +++ b/app/services/api/external-api/streaming/streaming.ts @@ -23,6 +23,7 @@ enum ERecordingState { Starting = 'starting', Recording = 'recording', Stopping = 'stopping', + Start = 'start', Wrote = 'wrote', } @@ -34,6 +35,7 @@ enum EReplayBufferState { Stopping = 'stopping', Offline = 'offline', Saving = 'saving', + Wrote = 'wrote', } /** @@ -42,9 +44,13 @@ enum EReplayBufferState { */ interface IStreamingState { streamingStatus: EStreamingState; + verticalStreamingStatus: EStreamingState; streamingStatusTime: string; + verticalStreamingStatusTime: string; recordingStatus: ERecordingState; + verticalRecordingStatus: ERecordingState; recordingStatusTime: string; + verticalRecordingStatusTime: string; replayBufferStatus: EReplayBufferState; replayBufferStatusTime: string; streamErrorCreated?: string; diff --git a/app/services/streaming/streaming-api.ts b/app/services/streaming/streaming-api.ts index a03010d67696..d02f29d11809 100644 --- a/app/services/streaming/streaming-api.ts +++ b/app/services/streaming/streaming-api.ts @@ -26,6 +26,7 @@ export enum ERecordingState { Starting = 'starting', Recording = 'recording', Stopping = 'stopping', + Start = 'start', Wrote = 'wrote', } @@ -34,6 +35,7 @@ export enum EReplayBufferState { Stopping = 'stopping', Offline = 'offline', Saving = 'saving', + Wrote = 'wrote', } export interface IStreamInfo { diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index b2020bd600e0..ed08653954cb 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -12,6 +12,10 @@ import { SimpleRecordingFactory, VideoEncoderFactory, EOutputSignal, + SimpleReplayBufferFactory, + ISimpleReplayBuffer, + AdvancedReplayBufferFactory, + IAdvancedReplayBuffer, } from '../../../obs-api'; import { Inject } from 'services/core/injector'; import moment from 'moment'; @@ -66,8 +70,9 @@ import { RecordingModeService } from 'services/recording-mode'; import { MarkersService } from 'services/markers'; import { byOS, OS } from 'util/operating-systems'; import { DualOutputService } from 'services/dual-output'; -import { capitalize } from 'lodash'; +import { capitalize, isFunction } from 'lodash'; import { YoutubeService } from 'app-services'; +import path from 'path'; enum EOBSOutputType { Streaming = 'streaming', @@ -80,18 +85,29 @@ enum EOBSOutputSignal { Start = 'start', Stopping = 'stopping', Stop = 'stop', + Activate = 'activate', Deactivate = 'deactivate', Reconnect = 'reconnect', ReconnectSuccess = 'reconnect_success', Wrote = 'wrote', + Writing = 'writing', WriteError = 'writing_error', } enum EOutputSignalState { + Saving = 'saving', + Starting = 'starting', Start = 'start', - Stop = 'stop', Stopping = 'stopping', + Stop = 'stop', + Activate = 'activate', + Deactivate = 'deactivate', + Reconnect = 'reconnect', + ReconnectSuccess = 'reconnect_success', + Running = 'running', Wrote = 'wrote', + Writing = 'writing', + WriteError = 'writing_error', } export interface IOBSOutputSignalInfo { type: EOBSOutputType; @@ -141,6 +157,9 @@ export class StreamingService private horizontalRecording: ISimpleRecording | IAdvancedRecording = null; private verticalRecording: ISimpleRecording | IAdvancedRecording = null; + private horizontalReplayBuffer: ISimpleReplayBuffer | IAdvancedReplayBuffer = null; + private verticalReplayBuffer: ISimpleReplayBuffer | IAdvancedReplayBuffer = null; + static initialState: IStreamingServiceState = { streamingStatus: EStreamingState.Offline, verticalStreamingStatus: EStreamingState.Offline, @@ -1134,7 +1153,7 @@ export class StreamingService this.state.recordingStatus === ERecordingState.Recording && this.state.verticalRecordingStatus === ERecordingState.Recording ) { - // stop recroding both displays + // stop recording both displays let time = new Date().toISOString(); if (this.verticalRecording !== null) { @@ -1227,7 +1246,8 @@ export class StreamingService // set signal handler recording.signalHandler = async signal => { - await this.handleRecordingSignal(signal, display); + console.log('recording signal', signal); + await this.handleSignal(signal, display); }; // handle unique properties (including audio) @@ -1264,6 +1284,7 @@ export class StreamingService [EOutputSignalState.Stopping]: ERecordingState.Stopping, [EOutputSignalState.Wrote]: ERecordingState.Wrote, } as Dictionary<ERecordingState>)[info.signal]; + console.log('handleRecordingSignal nextState', nextState, 'signal', info.signal); // We received a signal we didn't recognize if (!nextState) return; @@ -1338,26 +1359,189 @@ export class StreamingService } } - startReplayBuffer() { + private async handleSignal(info: EOutputSignal, display: TDisplayType) { + if (info.type === EOBSOutputType.Recording) { + await this.handleRecordingSignal(info, display); + return; + } + + if (info.type === EOBSOutputType.ReplayBuffer) { + this.handleReplayBufferSignal(info, display); + return; + } + + if (info.type === EOBSOutputType.Streaming) { + this.handleStreamingSignal(info, display); + return; + } + } + + private handleReplayBufferSignal(info: EOutputSignal, display: TDisplayType) { + // map signals to status + const nextState: EReplayBufferState = ({ + [EOBSOutputSignal.Start]: EReplayBufferState.Running, + [EOBSOutputSignal.Writing]: EReplayBufferState.Saving, + [EOBSOutputSignal.Wrote]: EReplayBufferState.Running, + [EOBSOutputSignal.Stop]: EReplayBufferState.Offline, + } as Dictionary<EReplayBufferState>)[info.signal]; + console.log('handleReplayBufferSignal nextState', nextState, 'signal', info.signal); + + if (nextState) { + const time = new Date().toISOString(); + this.SET_REPLAY_BUFFER_STATUS(nextState, time); + this.replayBufferStatusChange.next(nextState); + } + + if (info.signal === EOBSOutputSignal.Wrote) { + this.usageStatisticsService.recordAnalyticsEvent('ReplayBufferStatus', { + status: 'wrote', + code: info.code, + }); + this.replayBufferFileWrite.next(NodeObs.OBS_service_getLastReplay()); + } + } + + // TODO migrate streaming to new API + private handleStreamingSignal(info: EOutputSignal, display: TDisplayType) { + // map signals to status + } + + startReplayBuffer(display: TDisplayType = 'horizontal') { + if (this.state.replayBufferStatus !== EReplayBufferState.Offline) return; + + const mode = this.outputSettingsService.getSettings().mode; + if (!this.horizontalRecording) return; + if (this.state.replayBufferStatus !== EReplayBufferState.Offline) return; - this.usageStatisticsService.recordFeatureUsage('ReplayBuffer'); - NodeObs.OBS_service_startReplayBuffer(); + if (this.horizontalReplayBuffer) { + if (mode === 'Advanced') { + AdvancedReplayBufferFactory.destroy(this.horizontalReplayBuffer as IAdvancedReplayBuffer); + } else { + SimpleReplayBufferFactory.destroy(this.horizontalReplayBuffer as ISimpleReplayBuffer); + } + } + + if (mode === 'Advanced') { + this.horizontalReplayBuffer = AdvancedReplayBufferFactory.create(); + } else { + const replayBuffer = SimpleReplayBufferFactory.create(); + const recordingSettings = this.outputSettingsService.getSimpleRecordingSettings(); + + // const advancedSettings = this.outputSettingsService.getAdvancedRecordingSettings(); + // console.log('recordingSettings', recordingSettings); + // console.log('advancedSettings', advancedSettings); + // recordingSettings { + // path: 'C:\\Users\\miche\\Videos', + // format: 'mp4', + // quality: 1, + // encoder: 'jim_nvenc', + // lowCPU: false, + // overwrite: false, + // noSpace: false + // } + // advancedSettings { + // path: undefined, + // format: 'mp4', + // encoder: 'jim_nvenc', + // overwrite: false, + // noSpace: undefined, + // rescaling: undefined, + // mixer: undefined, + // useStreamEncoders: false + // } + + replayBuffer.path = recordingSettings.path; + replayBuffer.format = recordingSettings.format; + replayBuffer.overwrite = recordingSettings.overwrite; + replayBuffer.noSpace = recordingSettings.noSpace; + replayBuffer.video = this.videoSettingsService.contexts[display]; // TODO: Add vertical context + replayBuffer.duration = 60; + replayBuffer.prefix = 'Prefix'; + replayBuffer.suffix = 'Suffix'; + replayBuffer.usesStream = true; + replayBuffer.recording = this.horizontalRecording as ISimpleRecording; + replayBuffer.signalHandler = async signal => { + console.log('replay buffer signal', signal); + await this.handleSignal(signal, 'horizontal'); + }; + + this.horizontalReplayBuffer = replayBuffer; + this.horizontalReplayBuffer.start(); + this.usageStatisticsService.recordFeatureUsage('ReplayBuffer'); + } } - stopReplayBuffer() { - if (this.state.replayBufferStatus === EReplayBufferState.Running) { - NodeObs.OBS_service_stopReplayBuffer(false); - } else if (this.state.replayBufferStatus === EReplayBufferState.Stopping) { - NodeObs.OBS_service_stopReplayBuffer(true); + stopReplayBuffer(display: TDisplayType = 'horizontal') { + if (this.state.replayBufferStatus === EReplayBufferState.Offline) return; + const forceStop = this.state.replayBufferStatus === EReplayBufferState.Stopping; + console.log('stopReplayBuffer mode', this.outputSettingsService.getSettings().mode); + console.log( + 'stopReplayBuffer before destroy this.horizontalReplayBuffer', + this.horizontalReplayBuffer, + ); + console.log( + 'stopReplayBuffer before destroy this.verticalReplayBuffer', + this.verticalReplayBuffer, + ); + + if (this.outputSettingsService.getSettings().mode === 'Advanced') { + if (display === 'horizontal' && this.horizontalReplayBuffer) { + // TODO: save before stopping + // this.horizontalReplayBuffer.save(); + this.horizontalReplayBuffer.stop(forceStop); + AdvancedReplayBufferFactory.destroy(this.horizontalReplayBuffer as IAdvancedReplayBuffer); + this.horizontalReplayBuffer = null; + } + + if (display === 'vertical' && this.verticalReplayBuffer) { + // TODO: save before stopping + // this.verticalReplayBuffer.save(); + this.verticalReplayBuffer.stop(forceStop); + AdvancedReplayBufferFactory.destroy(this.verticalReplayBuffer as IAdvancedReplayBuffer); + this.verticalReplayBuffer = null; + } + } else { + if (display === 'horizontal' && this.horizontalReplayBuffer) { + // TODO: save before stopping + // this.horizontalReplayBuffer.save(); + this.horizontalReplayBuffer.stop(forceStop); + SimpleReplayBufferFactory.destroy(this.horizontalReplayBuffer as ISimpleReplayBuffer); + this.horizontalReplayBuffer = null; + } + + if (display === 'vertical' && this.verticalReplayBuffer) { + // TODO: save before stopping + // this.horizontalReplayBuffer.save(); + this.verticalReplayBuffer.stop(forceStop); + SimpleReplayBufferFactory.destroy(this.verticalReplayBuffer as ISimpleReplayBuffer); + this.verticalReplayBuffer = null; + } } + + console.log( + 'stopReplayBuffer after destroy this.horizontalReplayBuffer', + this.horizontalReplayBuffer, + ); + console.log( + 'stopReplayBuffer after destroy this.verticalReplayBuffer', + this.verticalReplayBuffer, + ); + + // TODO: Determine why replay buffer stop signals are missing + // temporarily set replay buffer status to offline + this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Offline); } - saveReplay() { - if (this.state.replayBufferStatus === EReplayBufferState.Running) { - this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Saving); - this.replayBufferStatusChange.next(EReplayBufferState.Saving); - NodeObs.OBS_service_processReplayBufferHotkey(); + saveReplay(display: TDisplayType = 'horizontal') { + if (display === 'horizontal' && this.horizontalReplayBuffer) { + this.horizontalReplayBuffer.save(); + return; + } + + if (display === 'vertical' && this.verticalReplayBuffer) { + this.verticalReplayBuffer.save(); + return; } } From f4f12501698ae684464b7bd99dfbc4bb2a48d3ed Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Wed, 5 Mar 2025 11:05:39 -0500 Subject: [PATCH 03/25] Fix replay buffer signals. --- app/components-react/root/StudioFooter.tsx | 2 +- .../settings/output/output-settings.ts | 25 +++++ app/services/streaming/streaming.ts | 99 +++++++++++-------- 3 files changed, 82 insertions(+), 44 deletions(-) diff --git a/app/components-react/root/StudioFooter.tsx b/app/components-react/root/StudioFooter.tsx index 3f57fe081afc..a54d47b5f941 100644 --- a/app/components-react/root/StudioFooter.tsx +++ b/app/components-react/root/StudioFooter.tsx @@ -46,7 +46,7 @@ export default function StudioFooterComponent() { replayBufferStopping: StreamingService.state.replayBufferStatus === EReplayBufferState.Stopping, replayBufferSaving: StreamingService.state.replayBufferStatus === EReplayBufferState.Saving, recordingModeEnabled: RecordingModeService.views.isRecordingModeEnabled, - replayBufferEnabled: SettingsService.views.values.Output.RecRB, + replayBufferEnabled: StreamingService.state.recordingStatus !== ERecordingState.Offline, })); function performanceIconClassName() { diff --git a/app/services/settings/output/output-settings.ts b/app/services/settings/output/output-settings.ts index 517cb19db660..e910d0664b90 100644 --- a/app/services/settings/output/output-settings.ts +++ b/app/services/settings/output/output-settings.ts @@ -282,6 +282,22 @@ export class OutputSettingsService extends Service { 'FileNameWithoutSpace', ); + const prefix: string = this.settingsService.findSettingValue( + output, + 'Recording', + 'RecRBPrefix', + ); + const suffix: string = this.settingsService.findSettingValue( + output, + 'Recording', + 'RecRBSuffix', + ); + const duration: number = this.settingsService.findSettingValue( + output, + 'Stream Delay', + 'DelaySec', + ); + return { path, format, @@ -290,6 +306,9 @@ export class OutputSettingsService extends Service { lowCPU, overwrite, noSpace, + prefix, + suffix, + duration, }; } @@ -301,6 +320,9 @@ export class OutputSettingsService extends Service { const encoder = this.settingsService.findSettingValue(output, 'Recording', 'RecEncoder'); const rescaling = this.settingsService.findSettingValue(output, 'Recording', 'RecRescale'); const mixer = this.settingsService.findSettingValue(output, 'Recording', 'RecTracks'); + const prefix = this.settingsService.findSettingValue(output, 'Recording', 'RecRBPrefix'); + const suffix = this.settingsService.findSettingValue(output, 'Recording', 'RecRBSuffix'); + const duration = this.settingsService.findSettingValue(output, 'Stream Delay', 'DelaySec'); const useStreamEncoders = this.settingsService.findSettingValue(output, 'Recording', 'RecEncoder') === 'none'; @@ -330,6 +352,9 @@ export class OutputSettingsService extends Service { noSpace, rescaling, mixer, + prefix, + suffix, + duration, useStreamEncoders, }; } diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index ed08653954cb..592064d2f998 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -1232,6 +1232,8 @@ export class StreamingService ? this.outputSettingsService.getAdvancedRecordingSettings() : this.outputSettingsService.getSimpleRecordingSettings(); + console.log('createRecording advanced settings', settings); + // assign settings Object.keys(settings).forEach(key => { if (key === 'encoder') { @@ -1382,6 +1384,7 @@ export class StreamingService [EOBSOutputSignal.Start]: EReplayBufferState.Running, [EOBSOutputSignal.Writing]: EReplayBufferState.Saving, [EOBSOutputSignal.Wrote]: EReplayBufferState.Running, + [EOBSOutputSignal.Stopping]: EReplayBufferState.Stopping, [EOBSOutputSignal.Stop]: EReplayBufferState.Offline, } as Dictionary<EReplayBufferState>)[info.signal]; console.log('handleReplayBufferSignal nextState', nextState, 'signal', info.signal); @@ -1399,6 +1402,35 @@ export class StreamingService }); this.replayBufferFileWrite.next(NodeObs.OBS_service_getLastReplay()); } + + if (info.signal === EOBSOutputSignal.Stop) { + this.usageStatisticsService.recordAnalyticsEvent('ReplayBufferStatus', { + status: 'stop', + code: info.code, + }); + + if (this.outputSettingsService.getSettings().mode === 'Advanced') { + if (display === 'horizontal' && this.horizontalReplayBuffer) { + AdvancedReplayBufferFactory.destroy(this.horizontalReplayBuffer as IAdvancedReplayBuffer); + this.horizontalReplayBuffer = null; + } + + if (display === 'vertical' && this.verticalReplayBuffer) { + AdvancedReplayBufferFactory.destroy(this.verticalReplayBuffer as IAdvancedReplayBuffer); + this.verticalReplayBuffer = null; + } + } else { + if (display === 'horizontal' && this.horizontalReplayBuffer) { + SimpleReplayBufferFactory.destroy(this.horizontalReplayBuffer as ISimpleReplayBuffer); + this.horizontalReplayBuffer = null; + } + + if (display === 'vertical' && this.verticalReplayBuffer) { + SimpleReplayBufferFactory.destroy(this.verticalReplayBuffer as ISimpleReplayBuffer); + this.verticalReplayBuffer = null; + } + } + } } // TODO migrate streaming to new API @@ -1424,41 +1456,38 @@ export class StreamingService if (mode === 'Advanced') { this.horizontalReplayBuffer = AdvancedReplayBufferFactory.create(); + const recordingSettings = this.outputSettingsService.getAdvancedRecordingSettings(); + + this.horizontalReplayBuffer.path = recordingSettings.path; + this.horizontalReplayBuffer.format = recordingSettings.format; + this.horizontalReplayBuffer.overwrite = recordingSettings.overwrite; + this.horizontalReplayBuffer.noSpace = recordingSettings.noSpace; + this.horizontalReplayBuffer.duration = recordingSettings.duration; + this.horizontalReplayBuffer.video = this.videoSettingsService.contexts[display]; + this.horizontalReplayBuffer.prefix = recordingSettings.prefix; + this.horizontalReplayBuffer.suffix = recordingSettings.suffix; + this.horizontalReplayBuffer.usesStream = recordingSettings.useStreamEncoders; + this.horizontalReplayBuffer.mixer = recordingSettings.mixer; + this.horizontalReplayBuffer.recording = this.horizontalRecording as IAdvancedRecording; + this.horizontalReplayBuffer.signalHandler = async signal => { + console.log('replay buffer signal', signal); + await this.handleSignal(signal, display); + }; + + this.horizontalReplayBuffer.start(); + this.usageStatisticsService.recordFeatureUsage('ReplayBuffer'); } else { const replayBuffer = SimpleReplayBufferFactory.create(); const recordingSettings = this.outputSettingsService.getSimpleRecordingSettings(); - // const advancedSettings = this.outputSettingsService.getAdvancedRecordingSettings(); - // console.log('recordingSettings', recordingSettings); - // console.log('advancedSettings', advancedSettings); - // recordingSettings { - // path: 'C:\\Users\\miche\\Videos', - // format: 'mp4', - // quality: 1, - // encoder: 'jim_nvenc', - // lowCPU: false, - // overwrite: false, - // noSpace: false - // } - // advancedSettings { - // path: undefined, - // format: 'mp4', - // encoder: 'jim_nvenc', - // overwrite: false, - // noSpace: undefined, - // rescaling: undefined, - // mixer: undefined, - // useStreamEncoders: false - // } - replayBuffer.path = recordingSettings.path; replayBuffer.format = recordingSettings.format; replayBuffer.overwrite = recordingSettings.overwrite; replayBuffer.noSpace = recordingSettings.noSpace; - replayBuffer.video = this.videoSettingsService.contexts[display]; // TODO: Add vertical context - replayBuffer.duration = 60; - replayBuffer.prefix = 'Prefix'; - replayBuffer.suffix = 'Suffix'; + replayBuffer.video = this.videoSettingsService.contexts[display]; + replayBuffer.duration = recordingSettings.duration; + replayBuffer.prefix = recordingSettings.prefix; + replayBuffer.suffix = recordingSettings.suffix; replayBuffer.usesStream = true; replayBuffer.recording = this.horizontalRecording as ISimpleRecording; replayBuffer.signalHandler = async signal => { @@ -1487,35 +1516,19 @@ export class StreamingService if (this.outputSettingsService.getSettings().mode === 'Advanced') { if (display === 'horizontal' && this.horizontalReplayBuffer) { - // TODO: save before stopping - // this.horizontalReplayBuffer.save(); this.horizontalReplayBuffer.stop(forceStop); - AdvancedReplayBufferFactory.destroy(this.horizontalReplayBuffer as IAdvancedReplayBuffer); - this.horizontalReplayBuffer = null; } if (display === 'vertical' && this.verticalReplayBuffer) { - // TODO: save before stopping - // this.verticalReplayBuffer.save(); this.verticalReplayBuffer.stop(forceStop); - AdvancedReplayBufferFactory.destroy(this.verticalReplayBuffer as IAdvancedReplayBuffer); - this.verticalReplayBuffer = null; } } else { if (display === 'horizontal' && this.horizontalReplayBuffer) { - // TODO: save before stopping - // this.horizontalReplayBuffer.save(); this.horizontalReplayBuffer.stop(forceStop); - SimpleReplayBufferFactory.destroy(this.horizontalReplayBuffer as ISimpleReplayBuffer); - this.horizontalReplayBuffer = null; } if (display === 'vertical' && this.verticalReplayBuffer) { - // TODO: save before stopping - // this.horizontalReplayBuffer.save(); this.verticalReplayBuffer.stop(forceStop); - SimpleReplayBufferFactory.destroy(this.verticalReplayBuffer as ISimpleReplayBuffer); - this.verticalReplayBuffer = null; } } From 4ce8c632097e7ce3cba824376a968a302a87f296 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Wed, 5 Mar 2025 13:32:02 -0500 Subject: [PATCH 04/25] Refactor to make less DRY and added shutdown for output contexts. --- app/services/app/app.ts | 3 + .../settings/output/output-settings.ts | 10 +- app/services/streaming/streaming-api.ts | 10 + app/services/streaming/streaming.ts | 309 +++++++++++------- 4 files changed, 218 insertions(+), 114 deletions(-) diff --git a/app/services/app/app.ts b/app/services/app/app.ts index 58a0fff6cd2f..b4c40d678e5e 100644 --- a/app/services/app/app.ts +++ b/app/services/app/app.ts @@ -44,6 +44,7 @@ import { DualOutputService } from 'services/dual-output'; import { OS, getOS } from 'util/operating-systems'; import * as remote from '@electron/remote'; import { RealmService } from 'services/realm'; +import { StreamingService } from 'services/streaming'; interface IAppState { loading: boolean; @@ -96,6 +97,7 @@ export class AppService extends StatefulService<IAppState> { @Inject() private videoSettingsService: VideoSettingsService; @Inject() private dualOutputService: DualOutputService; @Inject() private realmService: RealmService; + @Inject() private streamingService: StreamingService; static initialState: IAppState = { loading: true, @@ -192,6 +194,7 @@ export class AppService extends StatefulService<IAppState> { this.shutdownStarted.next(); this.keyListenerService.shutdown(); this.platformAppsService.unloadAllApps(); + this.streamingService.shutdown(); await this.usageStatisticsService.flushEvents(); this.windowsService.shutdown(); this.ipcServerService.stopListening(); diff --git a/app/services/settings/output/output-settings.ts b/app/services/settings/output/output-settings.ts index e910d0664b90..2aa5bb86a715 100644 --- a/app/services/settings/output/output-settings.ts +++ b/app/services/settings/output/output-settings.ts @@ -317,7 +317,6 @@ export class OutputSettingsService extends Service { const advanced = this.settingsService.state.Advanced.formData; const path = this.settingsService.findSettingValue(output, 'Recording', 'RecFilePath'); - const encoder = this.settingsService.findSettingValue(output, 'Recording', 'RecEncoder'); const rescaling = this.settingsService.findSettingValue(output, 'Recording', 'RecRescale'); const mixer = this.settingsService.findSettingValue(output, 'Recording', 'RecTracks'); const prefix = this.settingsService.findSettingValue(output, 'Recording', 'RecRBPrefix'); @@ -326,6 +325,15 @@ export class OutputSettingsService extends Service { const useStreamEncoders = this.settingsService.findSettingValue(output, 'Recording', 'RecEncoder') === 'none'; + const convertedEncoderName: + | EObsSimpleEncoder.x264_lowcpu + | EObsAdvancedEncoder = this.convertEncoderToNewAPI(this.getSettings().recording.encoder); + + const encoder: EObsAdvancedEncoder = + convertedEncoderName === EObsSimpleEncoder.x264_lowcpu + ? EObsAdvancedEncoder.obs_x264 + : convertedEncoderName; + const format = this.settingsService.findValidListValue( output, 'Recording', diff --git a/app/services/streaming/streaming-api.ts b/app/services/streaming/streaming-api.ts index d02f29d11809..d11fcd98517a 100644 --- a/app/services/streaming/streaming-api.ts +++ b/app/services/streaming/streaming-api.ts @@ -97,7 +97,17 @@ export interface IPlatformFlags { video?: IVideo; } +export interface IOutputStatus { + streaming: EStreamingState; + streamingTime: string; + recording: ERecordingState; + recordingTime: string; + replayBuffer: EReplayBufferState; + replayBufferTime: string; +} + export interface IStreamingServiceState { + status: { [display: string]: IOutputStatus }; streamingStatus: EStreamingState; verticalStreamingStatus: EStreamingState; streamingStatusTime: string; diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index 592064d2f998..77a323ad49b8 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -16,6 +16,10 @@ import { ISimpleReplayBuffer, AdvancedReplayBufferFactory, IAdvancedReplayBuffer, + ISimpleStreaming, + IAdvancedStreaming, + AdvancedStreamingFactory, + SimpleStreamingFactory, } from '../../../obs-api'; import { Inject } from 'services/core/injector'; import moment from 'moment'; @@ -117,6 +121,12 @@ export interface IOBSOutputSignalInfo { service: string; // 'default' | 'vertical' } +interface IOutputContext { + streaming: ISimpleStreaming | IAdvancedStreaming; + recording: ISimpleRecording | IAdvancedRecording; + replayBuffer: ISimpleReplayBuffer | IAdvancedReplayBuffer; +} + export class StreamingService extends StatefulService<IStreamingServiceState> implements IStreamingServiceApi { @@ -154,13 +164,42 @@ export class StreamingService private resolveStartStreaming: Function = () => {}; private rejectStartStreaming: Function = () => {}; - private horizontalRecording: ISimpleRecording | IAdvancedRecording = null; - private verticalRecording: ISimpleRecording | IAdvancedRecording = null; - - private horizontalReplayBuffer: ISimpleReplayBuffer | IAdvancedReplayBuffer = null; - private verticalReplayBuffer: ISimpleReplayBuffer | IAdvancedReplayBuffer = null; + private contexts: { + [name: string]: IOutputContext; + horizontal: IOutputContext; + vertical: IOutputContext; + } = { + horizontal: { + streaming: null, + recording: null, + replayBuffer: null, + }, + vertical: { + streaming: null, + recording: null, + replayBuffer: null, + }, + }; static initialState: IStreamingServiceState = { + status: { + horizontal: { + streaming: EStreamingState.Offline, + streamingTime: new Date().toISOString(), + recording: ERecordingState.Offline, + recordingTime: new Date().toISOString(), + replayBuffer: EReplayBufferState.Offline, + replayBufferTime: new Date().toISOString(), + }, + vertical: { + streaming: EStreamingState.Offline, + streamingTime: new Date().toISOString(), + recording: ERecordingState.Offline, + recordingTime: new Date().toISOString(), + replayBuffer: EReplayBufferState.Offline, + replayBufferTime: new Date().toISOString(), + }, + }, streamingStatus: EStreamingState.Offline, verticalStreamingStatus: EStreamingState.Offline, streamingStatusTime: new Date().toISOString(), @@ -960,7 +999,7 @@ export class StreamingService NodeObs.OBS_service_stopStreaming(true, 'horizontal'); NodeObs.OBS_service_stopStreaming(true, 'vertical'); // Refactor when move streaming to new API - if (this.state.verticalStreamingStatus !== EStreamingState.Offline) { + if (this.state.status.vertical.streaming !== EStreamingState.Offline) { this.SET_VERTICAL_STREAMING_STATUS(EStreamingState.Offline); } } @@ -970,7 +1009,7 @@ export class StreamingService // Refactor when move streaming to new API const time = new Date().toISOString(); - if (this.state.verticalStreamingStatus === EStreamingState.Offline) { + if (this.state.status.vertical.streaming === EStreamingState.Offline) { this.SET_VERTICAL_STREAMING_STATUS(EStreamingState.Live, time); } @@ -1078,7 +1117,7 @@ export class StreamingService ) { NodeObs.OBS_service_stopStreaming(false, 'vertical'); // Refactor when move streaming to new API - if (this.state.verticalStreamingStatus !== EStreamingState.Offline) { + if (this.state.status.vertical.streaming !== EStreamingState.Offline) { this.SET_VERTICAL_STREAMING_STATUS(EStreamingState.Offline); } signalChanged.unsubscribe(); @@ -1096,7 +1135,7 @@ export class StreamingService const keepRecording = this.streamSettingsService.settings.keepRecordingWhenStreamStops; const isRecording = this.state.recordingStatus === ERecordingState.Recording || - this.state.verticalRecordingStatus === ERecordingState.Recording; + this.state.status.vertical.recording === ERecordingState.Recording; if (!keepRecording && isRecording) { this.toggleRecording(); } @@ -1118,11 +1157,11 @@ export class StreamingService if (this.state.streamingStatus === EStreamingState.Ending) { if ( this.views.isDualOutputMode && - this.state.verticalRecordingStatus === ERecordingState.Offline + this.state.status.vertical.recording === ERecordingState.Offline ) { NodeObs.OBS_service_stopStreaming(true, 'horizontal'); // Refactor when move streaming to new API - if (this.state.verticalStreamingStatus !== EStreamingState.Offline) { + if (this.state.status.vertical.streaming !== EStreamingState.Offline) { this.SET_VERTICAL_STREAMING_STATUS(EStreamingState.Offline); } NodeObs.OBS_service_stopStreaming(true, 'vertical'); @@ -1151,12 +1190,12 @@ export class StreamingService // stop recording if ( this.state.recordingStatus === ERecordingState.Recording && - this.state.verticalRecordingStatus === ERecordingState.Recording + this.state.status.vertical.recording === ERecordingState.Recording ) { // stop recording both displays let time = new Date().toISOString(); - if (this.verticalRecording !== null) { + if (this.contexts.vertical.recording !== null) { const recordingStopped = this.recordingStopped.subscribe(async () => { await new Promise(resolve => // sleep for 2 seconds to allow a different time stamp to be generated @@ -1165,8 +1204,8 @@ export class StreamingService setTimeout(() => { time = new Date().toISOString(); this.SET_RECORDING_STATUS(ERecordingState.Stopping, time, 'horizontal'); - if (this.horizontalRecording !== null) { - this.horizontalRecording.stop(); + if (this.contexts.horizontal.recording !== null) { + this.contexts.horizontal.recording.stop(); } }, 2000), ); @@ -1174,33 +1213,33 @@ export class StreamingService }); this.SET_RECORDING_STATUS(ERecordingState.Stopping, time, 'vertical'); - this.verticalRecording.stop(); + this.contexts.vertical.recording.stop(); this.recordingStopped.next(); } return; } else if ( - this.state.verticalRecordingStatus === ERecordingState.Recording && - this.verticalRecording !== null + this.state.status.vertical.recording === ERecordingState.Recording && + this.contexts.vertical.recording !== null ) { // stop recording vertical display const time = new Date().toISOString(); this.SET_RECORDING_STATUS(ERecordingState.Stopping, time, 'vertical'); - this.verticalRecording.stop(); + this.contexts.vertical.recording.stop(); } else if ( this.state.recordingStatus === ERecordingState.Recording && - this.horizontalRecording !== null + this.contexts.horizontal.recording !== null ) { const time = new Date().toISOString(); // stop recording horizontal display this.SET_RECORDING_STATUS(ERecordingState.Stopping, time, 'horizontal'); - this.horizontalRecording.stop(); + this.contexts.horizontal.recording.stop(); } // start recording if ( this.state.recordingStatus === ERecordingState.Offline && - this.state.verticalRecordingStatus === ERecordingState.Offline + this.state.status.vertical.recording === ERecordingState.Offline ) { if (this.views.isDualOutputMode) { if (this.state.streamingStatus !== EStreamingState.Offline) { @@ -1220,6 +1259,8 @@ export class StreamingService } private createRecording(display: TDisplayType, index: number) { + this.destroyRecordingContextIfExists(display); + const mode = this.outputSettingsService.getSettings().mode; const recording = @@ -1236,6 +1277,9 @@ export class StreamingService // assign settings Object.keys(settings).forEach(key => { + console.log('(settings as any)[key]', key, (settings as any)[key]); + if ((settings as any)[key] === undefined) return; + if (key === 'encoder') { recording.videoEncoder = VideoEncoderFactory.create(settings.encoder, 'video-encoder'); } else { @@ -1245,6 +1289,7 @@ export class StreamingService // assign context recording.video = this.videoSettingsService.contexts[display]; + console.log('assigned context'); // set signal handler recording.signalHandler = async signal => { @@ -1252,6 +1297,8 @@ export class StreamingService await this.handleSignal(signal, display); }; + console.log('assign signal handler'); + // handle unique properties (including audio) if (mode === 'Advanced') { (recording as IAdvancedRecording).outputWidth = this.dualOutputService.views.videoSettings[ @@ -1264,17 +1311,27 @@ export class StreamingService const trackName = `track${index}`; const track = AudioTrackFactory.create(160, trackName); AudioTrackFactory.setAtIndex(track, index); + + console.log('assigned audio track'); + + const videoEncoder = recording.videoEncoder; + const stream = AdvancedStreamingFactory.create() as IAdvancedStreaming; + stream.video = this.videoSettingsService.contexts[display]; + stream.videoEncoder = videoEncoder; + recording.videoEncoder = videoEncoder; + recording.streaming = stream; } else { (recording as ISimpleRecording).audioEncoder = AudioEncoderFactory.create(); } // save in state if (display === 'vertical') { - this.verticalRecording = recording; - this.verticalRecording.start(); + this.contexts.vertical.recording = recording; + this.contexts.vertical.recording.start(); } else { - this.horizontalRecording = recording; - this.horizontalRecording.start(); + this.contexts.horizontal.recording = recording; + console.log('this.contexts.horizontal.recording', this.contexts.horizontal.recording); + this.contexts.horizontal.recording.start(); } } @@ -1305,8 +1362,8 @@ export class StreamingService if (nextState === ERecordingState.Wrote) { const fileName = display === 'vertical' - ? this.verticalRecording.lastFile() - : this.horizontalRecording.lastFile(); + ? this.contexts.vertical.recording.lastFile() + : this.contexts.horizontal.recording.lastFile(); const parsedName = byOS({ [OS.Mac]: fileName, @@ -1322,23 +1379,7 @@ export class StreamingService await this.markersService.exportCsv(parsedName); // destroy recording factory instances - if (this.outputSettingsService.getSettings().mode === 'Advanced') { - if (display === 'horizontal' && this.horizontalRecording) { - AdvancedRecordingFactory.destroy(this.horizontalRecording as IAdvancedRecording); - this.horizontalRecording = null; - } else if (display === 'vertical' && this.verticalRecording) { - AdvancedRecordingFactory.destroy(this.verticalRecording as IAdvancedRecording); - this.verticalRecording = null; - } - } else { - if (display === 'horizontal' && this.horizontalRecording) { - SimpleRecordingFactory.destroy(this.horizontalRecording as ISimpleRecording); - this.horizontalRecording = null; - } else if (display === 'vertical' && this.verticalRecording) { - SimpleRecordingFactory.destroy(this.verticalRecording as ISimpleRecording); - this.verticalRecording = null; - } - } + this.destroyRecordingContextIfExists(display); const time = new Date().toISOString(); this.SET_RECORDING_STATUS(ERecordingState.Offline, time, display); @@ -1409,27 +1450,7 @@ export class StreamingService code: info.code, }); - if (this.outputSettingsService.getSettings().mode === 'Advanced') { - if (display === 'horizontal' && this.horizontalReplayBuffer) { - AdvancedReplayBufferFactory.destroy(this.horizontalReplayBuffer as IAdvancedReplayBuffer); - this.horizontalReplayBuffer = null; - } - - if (display === 'vertical' && this.verticalReplayBuffer) { - AdvancedReplayBufferFactory.destroy(this.verticalReplayBuffer as IAdvancedReplayBuffer); - this.verticalReplayBuffer = null; - } - } else { - if (display === 'horizontal' && this.horizontalReplayBuffer) { - SimpleReplayBufferFactory.destroy(this.horizontalReplayBuffer as ISimpleReplayBuffer); - this.horizontalReplayBuffer = null; - } - - if (display === 'vertical' && this.verticalReplayBuffer) { - SimpleReplayBufferFactory.destroy(this.verticalReplayBuffer as ISimpleReplayBuffer); - this.verticalReplayBuffer = null; - } - } + this.destroyReplayBufferContextIfExists(display); } } @@ -1442,39 +1463,34 @@ export class StreamingService if (this.state.replayBufferStatus !== EReplayBufferState.Offline) return; const mode = this.outputSettingsService.getSettings().mode; - if (!this.horizontalRecording) return; + if (!this.contexts.horizontal.recording) return; if (this.state.replayBufferStatus !== EReplayBufferState.Offline) return; - if (this.horizontalReplayBuffer) { - if (mode === 'Advanced') { - AdvancedReplayBufferFactory.destroy(this.horizontalReplayBuffer as IAdvancedReplayBuffer); - } else { - SimpleReplayBufferFactory.destroy(this.horizontalReplayBuffer as ISimpleReplayBuffer); - } - } + this.destroyReplayBufferContextIfExists(display); if (mode === 'Advanced') { - this.horizontalReplayBuffer = AdvancedReplayBufferFactory.create(); + this.contexts.horizontal.replayBuffer = AdvancedReplayBufferFactory.create(); const recordingSettings = this.outputSettingsService.getAdvancedRecordingSettings(); - this.horizontalReplayBuffer.path = recordingSettings.path; - this.horizontalReplayBuffer.format = recordingSettings.format; - this.horizontalReplayBuffer.overwrite = recordingSettings.overwrite; - this.horizontalReplayBuffer.noSpace = recordingSettings.noSpace; - this.horizontalReplayBuffer.duration = recordingSettings.duration; - this.horizontalReplayBuffer.video = this.videoSettingsService.contexts[display]; - this.horizontalReplayBuffer.prefix = recordingSettings.prefix; - this.horizontalReplayBuffer.suffix = recordingSettings.suffix; - this.horizontalReplayBuffer.usesStream = recordingSettings.useStreamEncoders; - this.horizontalReplayBuffer.mixer = recordingSettings.mixer; - this.horizontalReplayBuffer.recording = this.horizontalRecording as IAdvancedRecording; - this.horizontalReplayBuffer.signalHandler = async signal => { + this.contexts.horizontal.replayBuffer.path = recordingSettings.path; + this.contexts.horizontal.replayBuffer.format = recordingSettings.format; + this.contexts.horizontal.replayBuffer.overwrite = recordingSettings.overwrite; + this.contexts.horizontal.replayBuffer.noSpace = recordingSettings.noSpace; + this.contexts.horizontal.replayBuffer.duration = recordingSettings.duration; + this.contexts.horizontal.replayBuffer.video = this.videoSettingsService.contexts[display]; + this.contexts.horizontal.replayBuffer.prefix = recordingSettings.prefix; + this.contexts.horizontal.replayBuffer.suffix = recordingSettings.suffix; + this.contexts.horizontal.replayBuffer.usesStream = recordingSettings.useStreamEncoders; + this.contexts.horizontal.replayBuffer.mixer = recordingSettings.mixer; + this.contexts.horizontal.replayBuffer.recording = this.contexts.horizontal + .recording as IAdvancedRecording; + this.contexts.horizontal.replayBuffer.signalHandler = async signal => { console.log('replay buffer signal', signal); await this.handleSignal(signal, display); }; - this.horizontalReplayBuffer.start(); + this.contexts.horizontal.replayBuffer.start(); this.usageStatisticsService.recordFeatureUsage('ReplayBuffer'); } else { const replayBuffer = SimpleReplayBufferFactory.create(); @@ -1489,14 +1505,14 @@ export class StreamingService replayBuffer.prefix = recordingSettings.prefix; replayBuffer.suffix = recordingSettings.suffix; replayBuffer.usesStream = true; - replayBuffer.recording = this.horizontalRecording as ISimpleRecording; + replayBuffer.recording = this.contexts.horizontal.recording as ISimpleRecording; replayBuffer.signalHandler = async signal => { console.log('replay buffer signal', signal); await this.handleSignal(signal, 'horizontal'); }; - this.horizontalReplayBuffer = replayBuffer; - this.horizontalReplayBuffer.start(); + this.contexts.horizontal.replayBuffer = replayBuffer; + this.contexts.horizontal.replayBuffer.start(); this.usageStatisticsService.recordFeatureUsage('ReplayBuffer'); } } @@ -1506,39 +1522,39 @@ export class StreamingService const forceStop = this.state.replayBufferStatus === EReplayBufferState.Stopping; console.log('stopReplayBuffer mode', this.outputSettingsService.getSettings().mode); console.log( - 'stopReplayBuffer before destroy this.horizontalReplayBuffer', - this.horizontalReplayBuffer, + 'stopReplayBuffer before destroy this.contexts.horizontal.replayBuffer', + this.contexts.horizontal.replayBuffer, ); console.log( 'stopReplayBuffer before destroy this.verticalReplayBuffer', - this.verticalReplayBuffer, + this.contexts.vertical.replayBuffer, ); if (this.outputSettingsService.getSettings().mode === 'Advanced') { - if (display === 'horizontal' && this.horizontalReplayBuffer) { - this.horizontalReplayBuffer.stop(forceStop); + if (display === 'horizontal' && this.contexts.horizontal.replayBuffer) { + this.contexts.horizontal.replayBuffer.stop(forceStop); } - if (display === 'vertical' && this.verticalReplayBuffer) { - this.verticalReplayBuffer.stop(forceStop); + if (display === 'vertical' && this.contexts.vertical.replayBuffer) { + this.contexts.vertical.replayBuffer.stop(forceStop); } } else { - if (display === 'horizontal' && this.horizontalReplayBuffer) { - this.horizontalReplayBuffer.stop(forceStop); + if (display === 'horizontal' && this.contexts.horizontal.replayBuffer) { + this.contexts.horizontal.replayBuffer.stop(forceStop); } - if (display === 'vertical' && this.verticalReplayBuffer) { - this.verticalReplayBuffer.stop(forceStop); + if (display === 'vertical' && this.contexts.vertical.replayBuffer) { + this.contexts.vertical.replayBuffer.stop(forceStop); } } console.log( - 'stopReplayBuffer after destroy this.horizontalReplayBuffer', - this.horizontalReplayBuffer, + 'stopReplayBuffer after destroy this.contexts.horizontal.replayBuffer', + this.contexts.horizontal.replayBuffer, ); console.log( 'stopReplayBuffer after destroy this.verticalReplayBuffer', - this.verticalReplayBuffer, + this.contexts.vertical.replayBuffer, ); // TODO: Determine why replay buffer stop signals are missing @@ -1547,13 +1563,13 @@ export class StreamingService } saveReplay(display: TDisplayType = 'horizontal') { - if (display === 'horizontal' && this.horizontalReplayBuffer) { - this.horizontalReplayBuffer.save(); + if (display === 'horizontal' && this.contexts.horizontal.replayBuffer) { + this.contexts.horizontal.replayBuffer.save(); return; } - if (display === 'vertical' && this.verticalReplayBuffer) { - this.verticalReplayBuffer.save(); + if (display === 'vertical' && this.contexts.vertical.replayBuffer) { + this.contexts.vertical.replayBuffer.save(); return; } } @@ -1640,8 +1656,8 @@ export class StreamingService // display the vertical recording status time if (this.state.recordingStatus !== ERecordingState.Offline) { this.formattedDurationSince(moment(this.state.recordingStatusTime)); - } else if (this.state.verticalRecordingStatus !== ERecordingState.Offline) { - return this.formattedDurationSince(moment(this.state.verticalRecordingStatusTime)); + } else if (this.state.status.vertical.recording !== ERecordingState.Offline) { + return this.formattedDurationSince(moment(this.state.status.vertical.recordingTime)); } return this.formattedDurationSince(moment(this.state.recordingStatusTime)); } @@ -2271,6 +2287,73 @@ export class StreamingService return fetch(request); } + /** + * Shut down the streaming service + * + * @remark Each streaming/recording/replay buffer context must be destroyed + * on app shutdown to prevent errors. + */ + shutdown() { + for (const display in this.contexts) { + this.destroyStreamingContextIfExists(display); + this.destroyRecordingContextIfExists(display); + this.destroyReplayBufferContextIfExists(display); + } + } + + /** + * Destroy the streaming context for a given display + * @remark Will just return if the context is null + * @param display - The display to destroy the streaming context for + */ + private destroyStreamingContextIfExists(display: TDisplayType | string) { + if (!this.contexts[display] || !this.contexts[display].streaming) return; + + if (this.outputSettingsService.getSettings().mode === 'Advanced') { + AdvancedStreamingFactory.destroy(this.contexts[display].streaming as IAdvancedStreaming); + } else { + SimpleStreamingFactory.destroy(this.contexts[display].streaming as ISimpleStreaming); + } + + this.contexts[display].streaming = null; + } + + /** + * Destroy the recording context for a given display + * @remark Will just return if the context is null + * @param display - The display to destroy the recording context for + */ + private destroyRecordingContextIfExists(display: TDisplayType | string) { + if (!this.contexts[display] || !this.contexts[display].recording) return; + + if (this.outputSettingsService.getSettings().mode === 'Advanced') { + AdvancedRecordingFactory.destroy(this.contexts[display].recording as IAdvancedRecording); + } else { + SimpleRecordingFactory.destroy(this.contexts[display].recording as ISimpleRecording); + } + + this.contexts[display].recording = null; + } + + /** + * Destroy the replay buffer context for a given display + * @remark Will just return if the context is null + * @param display - The display to destroy the replay buffer context for + */ + private destroyReplayBufferContextIfExists(display: TDisplayType | string) { + if (!this.contexts[display] || !this.contexts[display].replayBuffer) return; + + if (this.outputSettingsService.getSettings().mode === 'Advanced') { + AdvancedReplayBufferFactory.destroy( + this.contexts[display].replayBuffer as IAdvancedReplayBuffer, + ); + } else { + SimpleReplayBufferFactory.destroy(this.contexts[display].replayBuffer as ISimpleReplayBuffer); + } + + this.contexts[display].replayBuffer = null; + } + @mutation() private SET_STREAMING_STATUS(status: EStreamingState, time?: string) { this.state.streamingStatus = status; @@ -2290,8 +2373,8 @@ export class StreamingService display: TDisplayType = 'horizontal', ) { if (display === 'vertical') { - this.state.verticalRecordingStatus = status; - this.state.verticalRecordingStatusTime = time; + this.state.status.vertical.recording = status; + this.state.status.vertical.recordingTime = time; } else { this.state.recordingStatus = status; this.state.recordingStatusTime = time; From 605feec54bc42956a2a88b52427b4e7ebb95ad75 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Wed, 5 Mar 2025 13:46:57 -0500 Subject: [PATCH 05/25] Refactor destroy contexts. --- app/services/streaming/streaming.ts | 100 +++++++++++++--------------- 1 file changed, 48 insertions(+), 52 deletions(-) diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index 77a323ad49b8..e870db032ed3 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -1259,7 +1259,7 @@ export class StreamingService } private createRecording(display: TDisplayType, index: number) { - this.destroyRecordingContextIfExists(display); + this.destroyOutputContextIfExists(display, 'recording'); const mode = this.outputSettingsService.getSettings().mode; @@ -1379,7 +1379,7 @@ export class StreamingService await this.markersService.exportCsv(parsedName); // destroy recording factory instances - this.destroyRecordingContextIfExists(display); + this.destroyOutputContextIfExists(display, 'recording'); const time = new Date().toISOString(); this.SET_RECORDING_STATUS(ERecordingState.Offline, time, display); @@ -1450,7 +1450,7 @@ export class StreamingService code: info.code, }); - this.destroyReplayBufferContextIfExists(display); + this.destroyOutputContextIfExists(display, 'replayBuffer'); } } @@ -1467,7 +1467,7 @@ export class StreamingService if (this.state.replayBufferStatus !== EReplayBufferState.Offline) return; - this.destroyReplayBufferContextIfExists(display); + this.destroyOutputContextIfExists(display, 'replayBuffer'); if (mode === 'Advanced') { this.contexts.horizontal.replayBuffer = AdvancedReplayBufferFactory.create(); @@ -2294,64 +2294,60 @@ export class StreamingService * on app shutdown to prevent errors. */ shutdown() { - for (const display in this.contexts) { - this.destroyStreamingContextIfExists(display); - this.destroyRecordingContextIfExists(display); - this.destroyReplayBufferContextIfExists(display); - } - } - - /** - * Destroy the streaming context for a given display - * @remark Will just return if the context is null - * @param display - The display to destroy the streaming context for - */ - private destroyStreamingContextIfExists(display: TDisplayType | string) { - if (!this.contexts[display] || !this.contexts[display].streaming) return; - - if (this.outputSettingsService.getSettings().mode === 'Advanced') { - AdvancedStreamingFactory.destroy(this.contexts[display].streaming as IAdvancedStreaming); - } else { - SimpleStreamingFactory.destroy(this.contexts[display].streaming as ISimpleStreaming); - } - - this.contexts[display].streaming = null; - } - - /** - * Destroy the recording context for a given display - * @remark Will just return if the context is null - * @param display - The display to destroy the recording context for - */ - private destroyRecordingContextIfExists(display: TDisplayType | string) { - if (!this.contexts[display] || !this.contexts[display].recording) return; - - if (this.outputSettingsService.getSettings().mode === 'Advanced') { - AdvancedRecordingFactory.destroy(this.contexts[display].recording as IAdvancedRecording); - } else { - SimpleRecordingFactory.destroy(this.contexts[display].recording as ISimpleRecording); - } - - this.contexts[display].recording = null; + Object.keys(this.contexts).forEach(display => { + Object.keys(this.contexts[display]).forEach((contextType: keyof IOutputContext) => { + this.destroyOutputContextIfExists(display, contextType); + }); + }); } /** - * Destroy the replay buffer context for a given display + * Destroy the streaming context for a given display and output * @remark Will just return if the context is null - * @param display - The display to destroy the replay buffer context for + * @param display - The display to destroy the output context for + * @param contextType - The name of the output context to destroy */ - private destroyReplayBufferContextIfExists(display: TDisplayType | string) { - if (!this.contexts[display] || !this.contexts[display].replayBuffer) return; + private destroyOutputContextIfExists( + display: TDisplayType | string, + contextType: keyof IOutputContext, + ) { + if (!this.contexts[display] || !this.contexts[display][contextType]) return; if (this.outputSettingsService.getSettings().mode === 'Advanced') { - AdvancedReplayBufferFactory.destroy( - this.contexts[display].replayBuffer as IAdvancedReplayBuffer, - ); + switch (contextType) { + case 'streaming': + AdvancedStreamingFactory.destroy( + this.contexts[display][contextType] as IAdvancedStreaming, + ); + break; + case 'recording': + AdvancedRecordingFactory.destroy( + this.contexts[display][contextType] as IAdvancedRecording, + ); + break; + case 'replayBuffer': + AdvancedReplayBufferFactory.destroy( + this.contexts[display][contextType] as IAdvancedReplayBuffer, + ); + break; + } } else { - SimpleReplayBufferFactory.destroy(this.contexts[display].replayBuffer as ISimpleReplayBuffer); + switch (contextType) { + case 'streaming': + SimpleStreamingFactory.destroy(this.contexts[display][contextType] as ISimpleStreaming); + break; + case 'recording': + SimpleRecordingFactory.destroy(this.contexts[display][contextType] as ISimpleRecording); + break; + case 'replayBuffer': + SimpleReplayBufferFactory.destroy( + this.contexts[display][contextType] as ISimpleReplayBuffer, + ); + break; + } } - this.contexts[display].replayBuffer = null; + this.contexts[display][contextType] = null; } @mutation() From baf5af6f859345e3e99733159ec92fb4a46e9196 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Wed, 5 Mar 2025 14:37:52 -0500 Subject: [PATCH 06/25] Advanced recording works. --- .../settings/output/output-settings.ts | 147 ++++++++++++++++++ app/services/streaming/streaming.ts | 43 ++--- 2 files changed, 170 insertions(+), 20 deletions(-) diff --git a/app/services/settings/output/output-settings.ts b/app/services/settings/output/output-settings.ts index 2aa5bb86a715..e9cecdfc29e5 100644 --- a/app/services/settings/output/output-settings.ts +++ b/app/services/settings/output/output-settings.ts @@ -230,6 +230,128 @@ export class OutputSettingsService extends Service { }; } + /** + * Get recording settings + * @remark Primarily used for setting up the recording output context, + * this function will automatically return either the simple or advanced + * settings based on the current mode. + * @returns settings for the recording + */ + getRecordingSettings() { + const output = this.settingsService.state.Output.formData; + const advanced = this.settingsService.state.Advanced.formData; + + console.log('output', JSON.stringify(output, null, 2)); + console.log('advanced', JSON.stringify(advanced, null, 2)); + + const mode: TOutputSettingsMode = this.settingsService.findSettingValue( + output, + 'Untitled', + 'Mode', + ); + + const pathKey = mode === 'Advanced' ? 'RecFilePath' : 'FilePath'; + const path: string = this.settingsService.findSettingValue(output, 'Recording', pathKey); + + const format: ERecordingFormat = this.settingsService.findValidListValue( + output, + 'Recording', + 'RecFormat', + ) as ERecordingFormat; + + const oldQualityName = this.settingsService.findSettingValue(output, 'Recording', 'RecQuality'); + let quality: ERecordingQuality = ERecordingQuality.HigherQuality; + switch (oldQualityName) { + case 'Small': + quality = ERecordingQuality.HighQuality; + break; + case 'HQ': + quality = ERecordingQuality.HigherQuality; + break; + case 'Lossless': + quality = ERecordingQuality.Lossless; + break; + case 'Stream': + quality = ERecordingQuality.Stream; + break; + } + + const convertedEncoderName: + | EObsSimpleEncoder.x264_lowcpu + | EObsAdvancedEncoder = this.convertEncoderToNewAPI(this.getSettings().recording.encoder); + + const videoEncoder: EObsAdvancedEncoder = + convertedEncoderName === EObsSimpleEncoder.x264_lowcpu + ? EObsAdvancedEncoder.obs_x264 + : convertedEncoderName; + + const lowCPU: boolean = convertedEncoderName === EObsSimpleEncoder.x264_lowcpu; + + const overwrite: boolean = this.settingsService.findSettingValue( + advanced, + 'Recording', + 'OverwriteIfExists', + ); + + const noSpace: boolean = this.settingsService.findSettingValue( + output, + 'Recording', + 'FileNameWithoutSpace', + ); + + const prefix: string = this.settingsService.findSettingValue( + output, + 'Recording', + 'RecRBPrefix', + ); + const suffix: string = this.settingsService.findSettingValue( + output, + 'Recording', + 'RecRBSuffix', + ); + const duration: number = this.settingsService.findSettingValue( + output, + 'Stream Delay', + 'DelaySec', + ); + + if (mode === 'Advanced') { + const mixer = this.settingsService.findSettingValue(output, 'Recording', 'RecTracks'); + const rescaling = this.settingsService.findSettingValue(output, 'Recording', 'RecRescale'); + const useStreamEncoders = + this.settingsService.findSettingValue(output, 'Recording', 'RecEncoder') === 'none'; + + // advanced settings + return { + path, + format, + overwrite, + noSpace, + mixer, + rescaling, + useStreamEncoders, + videoEncoder, + prefix, + suffix, + duration, + }; + } else { + // simple settings + return { + path, + format, + quality, + videoEncoder, + lowCPU, + overwrite, + noSpace, + prefix, + suffix, + duration, + }; + } + } + getSimpleRecordingSettings() { const output = this.settingsService.state.Output.formData; const advanced = this.settingsService.state.Advanced.formData; @@ -311,6 +433,31 @@ export class OutputSettingsService extends Service { duration, }; } + // simple + // recording.path = path.join(path.normalize(__dirname), '..', 'osnData'); + // recording.format = ERecordingFormat.MOV; + // recording.quality = ERecordingQuality.HighQuality; + // recording.video = obs.defaultVideoContext; + // recording.videoEncoder = + // osn.VideoEncoderFactory.create('obs_x264', 'video-encoder'); + // recording.lowCPU = true; + // recording.audioEncoder = osn.AudioEncoderFactory.create(); + // recording.overwrite = true; + // recording.noSpace = false; + + // advanced + // recording.path = path.join(path.normalize(__dirname), '..', 'osnData'); + // recording.format = ERecordingFormat.MOV; + // recording.videoEncoder = + // osn.VideoEncoderFactory.create('obs_x264', 'video-encoder'); + // recording.overwrite = true; + // recording.noSpace = false; + // recording.video = obs.defaultVideoContext; + // recording.mixer = 7; + // recording.rescaling = true; + // recording.outputWidth = 1920; + // recording.outputHeight = 1080; + // recording.useStreamEncoders = false; getAdvancedRecordingSettings() { const output = this.settingsService.state.Output.formData; diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index e870db032ed3..cb2b1aeb6b70 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -1268,10 +1268,7 @@ export class StreamingService ? (AdvancedRecordingFactory.create() as IAdvancedRecording) : (SimpleRecordingFactory.create() as ISimpleRecording); - const settings = - mode === 'Advanced' - ? this.outputSettingsService.getAdvancedRecordingSettings() - : this.outputSettingsService.getSimpleRecordingSettings(); + const settings = this.outputSettingsService.getRecordingSettings(); console.log('createRecording advanced settings', settings); @@ -1280,8 +1277,8 @@ export class StreamingService console.log('(settings as any)[key]', key, (settings as any)[key]); if ((settings as any)[key] === undefined) return; - if (key === 'encoder') { - recording.videoEncoder = VideoEncoderFactory.create(settings.encoder, 'video-encoder'); + if (key === 'videoEncoder') { + recording.videoEncoder = VideoEncoderFactory.create(settings.videoEncoder, 'video-encoder'); } else { (recording as any)[key] = (settings as any)[key]; } @@ -1301,23 +1298,32 @@ export class StreamingService // handle unique properties (including audio) if (mode === 'Advanced') { - (recording as IAdvancedRecording).outputWidth = this.dualOutputService.views.videoSettings[ - display - ].outputWidth; - (recording as IAdvancedRecording).outputHeight = this.dualOutputService.views.videoSettings[ - display - ].outputHeight; + // output resolutions + const resolution = this.videoSettingsService.outputResolutions[display]; + (recording as IAdvancedRecording).outputWidth = resolution.outputWidth; + (recording as IAdvancedRecording).outputHeight = resolution.outputHeight; + // audio track const trackName = `track${index}`; const track = AudioTrackFactory.create(160, trackName); AudioTrackFactory.setAtIndex(track, index); console.log('assigned audio track'); + // streaming object const videoEncoder = recording.videoEncoder; const stream = AdvancedStreamingFactory.create() as IAdvancedStreaming; + stream.enforceServiceBitrate = false; + stream.enableTwitchVOD = false; + stream.audioTrack = index; + // stream.twitchTrack = 3; + stream.rescaling = ((recording as unknown) as IAdvancedStreaming).rescaling; + stream.outputWidth = ((recording as unknown) as IAdvancedStreaming).outputWidth; + stream.outputHeight = ((recording as unknown) as IAdvancedStreaming).outputHeight; stream.video = this.videoSettingsService.contexts[display]; stream.videoEncoder = videoEncoder; + + this.contexts[display].streaming = stream; recording.videoEncoder = videoEncoder; recording.streaming = stream; } else { @@ -1325,14 +1331,9 @@ export class StreamingService } // save in state - if (display === 'vertical') { - this.contexts.vertical.recording = recording; - this.contexts.vertical.recording.start(); - } else { - this.contexts.horizontal.recording = recording; - console.log('this.contexts.horizontal.recording', this.contexts.horizontal.recording); - this.contexts.horizontal.recording.start(); - } + this.contexts[display].recording = recording; + console.log('this.contexts[display].recording', this.contexts[display].recording); + this.contexts[display].recording.start(); } private async handleRecordingSignal(info: EOutputSignal, display: TDisplayType) { @@ -1380,6 +1381,8 @@ export class StreamingService // destroy recording factory instances this.destroyOutputContextIfExists(display, 'recording'); + // TODO: remove + this.destroyOutputContextIfExists(display, 'streaming'); const time = new Date().toISOString(); this.SET_RECORDING_STATUS(ERecordingState.Offline, time, display); From ab0b68a1bfdf932ea77ea0a2acdf771d3a855541 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Wed, 5 Mar 2025 15:07:16 -0500 Subject: [PATCH 07/25] Refactor to use new status handler. --- app/services/streaming/streaming.ts | 146 ++++++++++------------------ 1 file changed, 54 insertions(+), 92 deletions(-) diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index cb2b1aeb6b70..f14a3b0d8b17 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -915,7 +915,7 @@ export class StreamingService } get isReplayBufferActive() { - return this.state.replayBufferStatus !== EReplayBufferState.Offline; + return this.state.status.horizontal.replayBuffer !== EReplayBufferState.Offline; } get isIdle(): boolean { @@ -1037,7 +1037,10 @@ export class StreamingService const replayWhenStreaming = this.streamSettingsService.settings.replayBufferWhileStreaming; - if (replayWhenStreaming && this.state.replayBufferStatus === EReplayBufferState.Offline) { + if ( + replayWhenStreaming && + this.state.status.horizontal.replayBuffer === EReplayBufferState.Offline + ) { this.startReplayBuffer(); } @@ -1141,7 +1144,10 @@ export class StreamingService } const keepReplaying = this.streamSettingsService.settings.keepReplayBufferStreamStops; - if (!keepReplaying && this.state.replayBufferStatus === EReplayBufferState.Running) { + if ( + !keepReplaying && + this.state.status.horizontal.replayBuffer === EReplayBufferState.Running + ) { this.stopReplayBuffer(); } @@ -1203,7 +1209,7 @@ export class StreamingService // if the same time stamp is used, the entry will be replaced in the recording history setTimeout(() => { time = new Date().toISOString(); - this.SET_RECORDING_STATUS(ERecordingState.Stopping, time, 'horizontal'); + this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'horizontal', time); if (this.contexts.horizontal.recording !== null) { this.contexts.horizontal.recording.stop(); } @@ -1212,7 +1218,7 @@ export class StreamingService recordingStopped.unsubscribe(); }); - this.SET_RECORDING_STATUS(ERecordingState.Stopping, time, 'vertical'); + this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'vertical', time); this.contexts.vertical.recording.stop(); this.recordingStopped.next(); } @@ -1224,7 +1230,7 @@ export class StreamingService ) { // stop recording vertical display const time = new Date().toISOString(); - this.SET_RECORDING_STATUS(ERecordingState.Stopping, time, 'vertical'); + this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'vertical', time); this.contexts.vertical.recording.stop(); } else if ( this.state.recordingStatus === ERecordingState.Recording && @@ -1232,7 +1238,7 @@ export class StreamingService ) { const time = new Date().toISOString(); // stop recording horizontal display - this.SET_RECORDING_STATUS(ERecordingState.Stopping, time, 'horizontal'); + this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'horizontal', time); this.contexts.horizontal.recording.stop(); } @@ -1270,11 +1276,8 @@ export class StreamingService const settings = this.outputSettingsService.getRecordingSettings(); - console.log('createRecording advanced settings', settings); - // assign settings Object.keys(settings).forEach(key => { - console.log('(settings as any)[key]', key, (settings as any)[key]); if ((settings as any)[key] === undefined) return; if (key === 'videoEncoder') { @@ -1286,7 +1289,6 @@ export class StreamingService // assign context recording.video = this.videoSettingsService.contexts[display]; - console.log('assigned context'); // set signal handler recording.signalHandler = async signal => { @@ -1294,8 +1296,6 @@ export class StreamingService await this.handleSignal(signal, display); }; - console.log('assign signal handler'); - // handle unique properties (including audio) if (mode === 'Advanced') { // output resolutions @@ -1308,9 +1308,8 @@ export class StreamingService const track = AudioTrackFactory.create(160, trackName); AudioTrackFactory.setAtIndex(track, index); - console.log('assigned audio track'); - // streaming object + // TODO: move to its own function const videoEncoder = recording.videoEncoder; const stream = AdvancedStreamingFactory.create() as IAdvancedStreaming; stream.enforceServiceBitrate = false; @@ -1332,7 +1331,6 @@ export class StreamingService // save in state this.contexts[display].recording = recording; - console.log('this.contexts[display].recording', this.contexts[display].recording); this.contexts[display].recording.start(); } @@ -1344,7 +1342,6 @@ export class StreamingService [EOutputSignalState.Stopping]: ERecordingState.Stopping, [EOutputSignalState.Wrote]: ERecordingState.Wrote, } as Dictionary<ERecordingState>)[info.signal]; - console.log('handleRecordingSignal nextState', nextState, 'signal', info.signal); // We received a signal we didn't recognize if (!nextState) return; @@ -1381,11 +1378,11 @@ export class StreamingService // destroy recording factory instances this.destroyOutputContextIfExists(display, 'recording'); - // TODO: remove + // TODO: is this necessary? this.destroyOutputContextIfExists(display, 'streaming'); const time = new Date().toISOString(); - this.SET_RECORDING_STATUS(ERecordingState.Offline, time, display); + this.SET_RECORDING_STATUS(ERecordingState.Offline, display, time); this.recordingStatusChange.next(ERecordingState.Offline); this.handleV2OutputCode(info); @@ -1393,7 +1390,7 @@ export class StreamingService } const time = new Date().toISOString(); - this.SET_RECORDING_STATUS(nextState, time, display); + this.SET_RECORDING_STATUS(nextState, display, time); this.recordingStatusChange.next(nextState); this.handleV2OutputCode(info); @@ -1435,7 +1432,7 @@ export class StreamingService if (nextState) { const time = new Date().toISOString(); - this.SET_REPLAY_BUFFER_STATUS(nextState, time); + this.SET_REPLAY_BUFFER_STATUS(nextState, display, time); this.replayBufferStatusChange.next(nextState); } @@ -1463,18 +1460,18 @@ export class StreamingService } startReplayBuffer(display: TDisplayType = 'horizontal') { - if (this.state.replayBufferStatus !== EReplayBufferState.Offline) return; + if (this.state.status[display].replayBuffer !== EReplayBufferState.Offline) return; const mode = this.outputSettingsService.getSettings().mode; if (!this.contexts.horizontal.recording) return; - if (this.state.replayBufferStatus !== EReplayBufferState.Offline) return; + if (this.state.status[display].replayBuffer !== EReplayBufferState.Offline) return; this.destroyOutputContextIfExists(display, 'replayBuffer'); if (mode === 'Advanced') { this.contexts.horizontal.replayBuffer = AdvancedReplayBufferFactory.create(); - const recordingSettings = this.outputSettingsService.getAdvancedRecordingSettings(); + const recordingSettings = this.outputSettingsService.getRecordingSettings(); this.contexts.horizontal.replayBuffer.path = recordingSettings.path; this.contexts.horizontal.replayBuffer.format = recordingSettings.format; @@ -1497,7 +1494,7 @@ export class StreamingService this.usageStatisticsService.recordFeatureUsage('ReplayBuffer'); } else { const replayBuffer = SimpleReplayBufferFactory.create(); - const recordingSettings = this.outputSettingsService.getSimpleRecordingSettings(); + const recordingSettings = this.outputSettingsService.getRecordingSettings(); replayBuffer.path = recordingSettings.path; replayBuffer.format = recordingSettings.format; @@ -1521,60 +1518,19 @@ export class StreamingService } stopReplayBuffer(display: TDisplayType = 'horizontal') { - if (this.state.replayBufferStatus === EReplayBufferState.Offline) return; - const forceStop = this.state.replayBufferStatus === EReplayBufferState.Stopping; - console.log('stopReplayBuffer mode', this.outputSettingsService.getSettings().mode); - console.log( - 'stopReplayBuffer before destroy this.contexts.horizontal.replayBuffer', - this.contexts.horizontal.replayBuffer, - ); - console.log( - 'stopReplayBuffer before destroy this.verticalReplayBuffer', - this.contexts.vertical.replayBuffer, - ); - - if (this.outputSettingsService.getSettings().mode === 'Advanced') { - if (display === 'horizontal' && this.contexts.horizontal.replayBuffer) { - this.contexts.horizontal.replayBuffer.stop(forceStop); - } - - if (display === 'vertical' && this.contexts.vertical.replayBuffer) { - this.contexts.vertical.replayBuffer.stop(forceStop); - } - } else { - if (display === 'horizontal' && this.contexts.horizontal.replayBuffer) { - this.contexts.horizontal.replayBuffer.stop(forceStop); - } - - if (display === 'vertical' && this.contexts.vertical.replayBuffer) { - this.contexts.vertical.replayBuffer.stop(forceStop); - } + if ( + !this.contexts[display].replayBuffer || + this.state.status[display].replayBuffer === EReplayBufferState.Offline + ) { + return; } - - console.log( - 'stopReplayBuffer after destroy this.contexts.horizontal.replayBuffer', - this.contexts.horizontal.replayBuffer, - ); - console.log( - 'stopReplayBuffer after destroy this.verticalReplayBuffer', - this.contexts.vertical.replayBuffer, - ); - - // TODO: Determine why replay buffer stop signals are missing - // temporarily set replay buffer status to offline - this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Offline); + const forceStop = this.state.status[display].replayBuffer === EReplayBufferState.Stopping; + this.contexts[display].replayBuffer.stop(forceStop); } saveReplay(display: TDisplayType = 'horizontal') { - if (display === 'horizontal' && this.contexts.horizontal.replayBuffer) { - this.contexts.horizontal.replayBuffer.save(); - return; - } - - if (display === 'vertical' && this.contexts.vertical.replayBuffer) { - this.contexts.vertical.replayBuffer.save(); - return; - } + if (!this.contexts[display].replayBuffer) return; + this.contexts[display].replayBuffer.save(); } /** @@ -1802,7 +1758,7 @@ export class StreamingService } as Dictionary<EReplayBufferState>)[info.signal]; if (nextState) { - this.SET_REPLAY_BUFFER_STATUS(nextState, time); + this.SET_REPLAY_BUFFER_STATUS(nextState, 'horizontal', time); this.replayBufferStatusChange.next(nextState); } @@ -2048,7 +2004,7 @@ export class StreamingService return; } - this.SET_RECORDING_STATUS(nextState, time); + this.SET_RECORDING_STATUS(nextState, 'horizontal', time); this.recordingStatusChange.next(nextState); } else if (info.type === EOBSOutputType.ReplayBuffer) { const nextState: EReplayBufferState = ({ @@ -2060,7 +2016,7 @@ export class StreamingService } as Dictionary<EReplayBufferState>)[info.signal]; if (nextState) { - this.SET_REPLAY_BUFFER_STATUS(nextState, time); + this.SET_REPLAY_BUFFER_STATUS(nextState, 'horizontal', time); this.replayBufferStatusChange.next(nextState); } @@ -2366,24 +2322,30 @@ export class StreamingService } @mutation() - private SET_RECORDING_STATUS( - status: ERecordingState, - time: string, - display: TDisplayType = 'horizontal', - ) { - if (display === 'vertical') { - this.state.status.vertical.recording = status; - this.state.status.vertical.recordingTime = time; - } else { - this.state.recordingStatus = status; - this.state.recordingStatusTime = time; - } + private SET_RECORDING_STATUS(status: ERecordingState, display: TDisplayType, time: string) { + // while recording and the replay buffer are in the v2 API and streaming is in the old API + // we need to duplicate tracking the replay buffer status + this.state.status[display].recording = status; + this.state.status[display].recordingTime = time; + this.state.recordingStatus = status; + this.state.recordingStatusTime = time; } @mutation() - private SET_REPLAY_BUFFER_STATUS(status: EReplayBufferState, time?: string) { + private SET_REPLAY_BUFFER_STATUS( + status: EReplayBufferState, + display: TDisplayType, + time?: string, + ) { + // while recording and the replay buffer are in the v2 API and streaming is in the old API + // we need to duplicate tracking the replay buffer status + this.state.status[display].replayBuffer = status; this.state.replayBufferStatus = status; - if (time) this.state.replayBufferStatusTime = time; + + if (time) { + this.state.status[display].replayBufferTime = time; + this.state.replayBufferStatusTime = time; + } } @mutation() From 6bab2633ebb1b5f9e170d905fc00af568570a1d1 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Wed, 5 Mar 2025 16:27:24 -0500 Subject: [PATCH 08/25] Handling for dependent output instances. --- app/i18n/en-US/streaming.json | 3 +- app/services/streaming/streaming.ts | 181 +++++++++++++++++----------- 2 files changed, 113 insertions(+), 71 deletions(-) diff --git a/app/i18n/en-US/streaming.json b/app/i18n/en-US/streaming.json index 0d8c57b9106d..625039fd835a 100644 --- a/app/i18n/en-US/streaming.json +++ b/app/i18n/en-US/streaming.json @@ -288,5 +288,6 @@ "Please try again. If the issue persists, you can stream directly to a single platform instead or click the button below to bypass and go live.": "Please try again. If the issue persists, you can stream directly to a single platform instead or click the button below to bypass and go live.", "Issues": "Issues", "Multistream Error": "Multistream Error", - "One of destinations might have incomplete permissions. Reconnect the destinations in settings and try again.": "One of destinations might have incomplete permissions. Reconnect the destinations in settings and try again." + "One of destinations might have incomplete permissions. Reconnect the destinations in settings and try again.": "One of destinations might have incomplete permissions. Reconnect the destinations in settings and try again.", + "A new Highlight has been saved. Click to edit in the Highlighter": "A new Highlight has been saved. Click to edit in the Highlighter" } diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index f14a3b0d8b17..6f6cb0743cd9 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -1,5 +1,6 @@ import Vue from 'vue'; import { mutation, StatefulService } from 'services/core/stateful-service'; +import { Service } from 'services/core/service'; import { AdvancedRecordingFactory, AudioEncoderFactory, @@ -74,9 +75,10 @@ import { RecordingModeService } from 'services/recording-mode'; import { MarkersService } from 'services/markers'; import { byOS, OS } from 'util/operating-systems'; import { DualOutputService } from 'services/dual-output'; -import { capitalize, isFunction } from 'lodash'; +import { capitalize } from 'lodash'; import { YoutubeService } from 'app-services'; -import path from 'path'; +import { JsonrpcService } from '../api/jsonrpc'; +import { NavigationService } from 'services/navigation'; enum EOBSOutputType { Streaming = 'streaming', @@ -145,6 +147,8 @@ export class StreamingService @Inject() private markersService: MarkersService; @Inject() private dualOutputService: DualOutputService; @Inject() private youtubeService: YoutubeService; + @Inject() private jsonrpcService: JsonrpcService; + @Inject() private navigationService: NavigationService; streamingStatusChange = new Subject<EStreamingState>(); recordingStatusChange = new Subject<ERecordingState>(); @@ -1311,7 +1315,9 @@ export class StreamingService // streaming object // TODO: move to its own function const videoEncoder = recording.videoEncoder; - const stream = AdvancedStreamingFactory.create() as IAdvancedStreaming; + const stream = + (this.contexts[display].streaming as IAdvancedStreaming) ?? + (AdvancedStreamingFactory.create() as IAdvancedStreaming); stream.enforceServiceBitrate = false; stream.enableTwitchVOD = false; stream.audioTrack = index; @@ -1368,7 +1374,7 @@ export class StreamingService [OS.Windows]: fileName.replace(/\//, '\\'), }); - // in dual output mode, each confirmation should be labelled for each display + // In dual output mode, each confirmation should be labelled for each display if (this.views.isDualOutputMode) { this.recordingModeService.addRecordingEntry(parsedName, display); } else { @@ -1376,10 +1382,13 @@ export class StreamingService } await this.markersService.exportCsv(parsedName); - // destroy recording factory instances + // destroy recording instance this.destroyOutputContextIfExists(display, 'recording'); - // TODO: is this necessary? - this.destroyOutputContextIfExists(display, 'streaming'); + // Also destroy the streaming instance if it was only created for recording + // Note: this is only the case when recording without streaming in advanced mode + if (this.state.status[display].streaming === EStreamingState.Offline) { + this.destroyOutputContextIfExists(display, 'streaming'); + } const time = new Date().toISOString(); this.SET_RECORDING_STATUS(ERecordingState.Offline, display, time); @@ -1441,6 +1450,19 @@ export class StreamingService status: 'wrote', code: info.code, }); + + const message = $t('A new Highlight has been saved. Click to edit in the Highlighter'); + + this.notificationsService.actions.push({ + type: ENotificationType.SUCCESS, + message, + action: this.jsonrpcService.createRequest(Service.getResourceId(this), 'showHighlighter'), + }); + + console.log('NodeObs.OBS_service_getLastReplay()', NodeObs.OBS_service_getLastReplay()); + console.log('this.contexts[display].replayBuffer', this.contexts[display].replayBuffer); + console.log('this.contexts[display].recording', this.contexts[display].recording); + this.replayBufferFileWrite.next(NodeObs.OBS_service_getLastReplay()); } @@ -1454,6 +1476,10 @@ export class StreamingService } } + showHighlighter() { + this.navigationService.navigate('Highlighter'); + } + // TODO migrate streaming to new API private handleStreamingSignal(info: EOutputSignal, display: TDisplayType) { // map signals to status @@ -1463,39 +1489,51 @@ export class StreamingService if (this.state.status[display].replayBuffer !== EReplayBufferState.Offline) return; const mode = this.outputSettingsService.getSettings().mode; - if (!this.contexts.horizontal.recording) return; + if (!this.contexts[display].recording) return; if (this.state.status[display].replayBuffer !== EReplayBufferState.Offline) return; this.destroyOutputContextIfExists(display, 'replayBuffer'); + // the replay buffer must have a recording instance to reference + if ( + this.state.streamingStatus !== EStreamingState.Offline && + !this.contexts[display].recording + ) { + this.createRecording(display, 1); + } + if (mode === 'Advanced') { - this.contexts.horizontal.replayBuffer = AdvancedReplayBufferFactory.create(); + const replayBuffer = AdvancedReplayBufferFactory.create() as IAdvancedReplayBuffer; const recordingSettings = this.outputSettingsService.getRecordingSettings(); - this.contexts.horizontal.replayBuffer.path = recordingSettings.path; - this.contexts.horizontal.replayBuffer.format = recordingSettings.format; - this.contexts.horizontal.replayBuffer.overwrite = recordingSettings.overwrite; - this.contexts.horizontal.replayBuffer.noSpace = recordingSettings.noSpace; - this.contexts.horizontal.replayBuffer.duration = recordingSettings.duration; - this.contexts.horizontal.replayBuffer.video = this.videoSettingsService.contexts[display]; - this.contexts.horizontal.replayBuffer.prefix = recordingSettings.prefix; - this.contexts.horizontal.replayBuffer.suffix = recordingSettings.suffix; - this.contexts.horizontal.replayBuffer.usesStream = recordingSettings.useStreamEncoders; - this.contexts.horizontal.replayBuffer.mixer = recordingSettings.mixer; - this.contexts.horizontal.replayBuffer.recording = this.contexts.horizontal - .recording as IAdvancedRecording; - this.contexts.horizontal.replayBuffer.signalHandler = async signal => { + // console.log('Advanced recordingSettings', JSON.stringify(recordingSettings, null, 2)); + + replayBuffer.path = recordingSettings.path; + replayBuffer.format = recordingSettings.format; + replayBuffer.overwrite = recordingSettings.overwrite; + replayBuffer.noSpace = recordingSettings.noSpace; + replayBuffer.duration = recordingSettings.duration; + replayBuffer.video = this.videoSettingsService.contexts[display]; + replayBuffer.prefix = recordingSettings.prefix; + replayBuffer.suffix = recordingSettings.suffix; + replayBuffer.usesStream = recordingSettings.useStreamEncoders; + replayBuffer.mixer = recordingSettings.mixer; + replayBuffer.recording = this.contexts[display].recording as IAdvancedRecording; + replayBuffer.signalHandler = async signal => { console.log('replay buffer signal', signal); await this.handleSignal(signal, display); }; - this.contexts.horizontal.replayBuffer.start(); + this.contexts[display].replayBuffer = replayBuffer; + this.contexts[display].replayBuffer.start(); this.usageStatisticsService.recordFeatureUsage('ReplayBuffer'); } else { - const replayBuffer = SimpleReplayBufferFactory.create(); + const replayBuffer = SimpleReplayBufferFactory.create() as ISimpleReplayBuffer; const recordingSettings = this.outputSettingsService.getRecordingSettings(); + // console.log('Simple recordingSettings', JSON.stringify(recordingSettings, null, 2)); + replayBuffer.path = recordingSettings.path; replayBuffer.format = recordingSettings.format; replayBuffer.overwrite = recordingSettings.overwrite; @@ -1505,14 +1543,14 @@ export class StreamingService replayBuffer.prefix = recordingSettings.prefix; replayBuffer.suffix = recordingSettings.suffix; replayBuffer.usesStream = true; - replayBuffer.recording = this.contexts.horizontal.recording as ISimpleRecording; + replayBuffer.recording = this.contexts[display].recording as ISimpleRecording; replayBuffer.signalHandler = async signal => { console.log('replay buffer signal', signal); - await this.handleSignal(signal, 'horizontal'); + await this.handleSignal(signal, display); }; - this.contexts.horizontal.replayBuffer = replayBuffer; - this.contexts.horizontal.replayBuffer.start(); + this.contexts[display].replayBuffer = replayBuffer; + this.contexts[display].replayBuffer.start(); this.usageStatisticsService.recordFeatureUsage('ReplayBuffer'); } } @@ -1529,6 +1567,7 @@ export class StreamingService } saveReplay(display: TDisplayType = 'horizontal') { + console.log('this.contexts[display].replayBuffer', this.contexts[display].replayBuffer); if (!this.contexts[display].replayBuffer) return; this.contexts[display].replayBuffer.save(); } @@ -1748,28 +1787,29 @@ export class StreamingService this.streamingStatusChange.next(EStreamingState.Live); this.clearReconnectingNotification(); } - } else if (info.type === EOBSOutputType.ReplayBuffer) { - const nextState: EReplayBufferState = ({ - [EOBSOutputSignal.Start]: EReplayBufferState.Running, - [EOBSOutputSignal.Stopping]: EReplayBufferState.Stopping, - [EOBSOutputSignal.Stop]: EReplayBufferState.Offline, - [EOBSOutputSignal.Wrote]: EReplayBufferState.Running, - [EOBSOutputSignal.WriteError]: EReplayBufferState.Running, - } as Dictionary<EReplayBufferState>)[info.signal]; - - if (nextState) { - this.SET_REPLAY_BUFFER_STATUS(nextState, 'horizontal', time); - this.replayBufferStatusChange.next(nextState); - } - - if (info.signal === EOBSOutputSignal.Wrote) { - this.usageStatisticsService.recordAnalyticsEvent('ReplayBufferStatus', { - status: 'wrote', - code: info.code, - }); - this.replayBufferFileWrite.next(NodeObs.OBS_service_getLastReplay()); - } } + // else if (info.type === EOBSOutputType.ReplayBuffer) { + // const nextState: EReplayBufferState = ({ + // [EOBSOutputSignal.Start]: EReplayBufferState.Running, + // [EOBSOutputSignal.Stopping]: EReplayBufferState.Stopping, + // [EOBSOutputSignal.Stop]: EReplayBufferState.Offline, + // [EOBSOutputSignal.Wrote]: EReplayBufferState.Running, + // [EOBSOutputSignal.WriteError]: EReplayBufferState.Running, + // } as Dictionary<EReplayBufferState>)[info.signal]; + + // if (nextState) { + // this.SET_REPLAY_BUFFER_STATUS(nextState, 'horizontal', time); + // this.replayBufferStatusChange.next(nextState); + // } + + // if (info.signal === EOBSOutputSignal.Wrote) { + // this.usageStatisticsService.recordAnalyticsEvent('ReplayBufferStatus', { + // status: 'wrote', + // code: info.code, + // }); + // this.replayBufferFileWrite.next(NodeObs.OBS_service_getLastReplay()); + // } + // } this.handleV2OutputCode(info); } @@ -2006,28 +2046,29 @@ export class StreamingService this.SET_RECORDING_STATUS(nextState, 'horizontal', time); this.recordingStatusChange.next(nextState); - } else if (info.type === EOBSOutputType.ReplayBuffer) { - const nextState: EReplayBufferState = ({ - [EOBSOutputSignal.Start]: EReplayBufferState.Running, - [EOBSOutputSignal.Stopping]: EReplayBufferState.Stopping, - [EOBSOutputSignal.Stop]: EReplayBufferState.Offline, - [EOBSOutputSignal.Wrote]: EReplayBufferState.Running, - [EOBSOutputSignal.WriteError]: EReplayBufferState.Running, - } as Dictionary<EReplayBufferState>)[info.signal]; - - if (nextState) { - this.SET_REPLAY_BUFFER_STATUS(nextState, 'horizontal', time); - this.replayBufferStatusChange.next(nextState); - } - - if (info.signal === EOBSOutputSignal.Wrote) { - this.usageStatisticsService.recordAnalyticsEvent('ReplayBufferStatus', { - status: 'wrote', - code: info.code, - }); - this.replayBufferFileWrite.next(NodeObs.OBS_service_getLastReplay()); - } } + // else if (info.type === EOBSOutputType.ReplayBuffer) { + // const nextState: EReplayBufferState = ({ + // [EOBSOutputSignal.Start]: EReplayBufferState.Running, + // [EOBSOutputSignal.Stopping]: EReplayBufferState.Stopping, + // [EOBSOutputSignal.Stop]: EReplayBufferState.Offline, + // [EOBSOutputSignal.Wrote]: EReplayBufferState.Running, + // [EOBSOutputSignal.WriteError]: EReplayBufferState.Running, + // } as Dictionary<EReplayBufferState>)[info.signal]; + + // if (nextState) { + // this.SET_REPLAY_BUFFER_STATUS(nextState, 'horizontal', time); + // this.replayBufferStatusChange.next(nextState); + // } + + // if (info.signal === EOBSOutputSignal.Wrote) { + // this.usageStatisticsService.recordAnalyticsEvent('ReplayBufferStatus', { + // status: 'wrote', + // code: info.code, + // }); + // this.replayBufferFileWrite.next(NodeObs.OBS_service_getLastReplay()); + // } + // } if (info.code) { if (this.outputErrorOpen) { From 0873a4ca738cdd033c6cc61c01d0428d466b723b Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Wed, 5 Mar 2025 16:53:52 -0500 Subject: [PATCH 09/25] WIP: Streaming with replay buffer. --- app/components-react/root/StudioFooter.tsx | 2 +- app/services/streaming/streaming.ts | 122 +++------------------ 2 files changed, 17 insertions(+), 107 deletions(-) diff --git a/app/components-react/root/StudioFooter.tsx b/app/components-react/root/StudioFooter.tsx index a54d47b5f941..3f57fe081afc 100644 --- a/app/components-react/root/StudioFooter.tsx +++ b/app/components-react/root/StudioFooter.tsx @@ -46,7 +46,7 @@ export default function StudioFooterComponent() { replayBufferStopping: StreamingService.state.replayBufferStatus === EReplayBufferState.Stopping, replayBufferSaving: StreamingService.state.replayBufferStatus === EReplayBufferState.Saving, recordingModeEnabled: RecordingModeService.views.isRecordingModeEnabled, - replayBufferEnabled: StreamingService.state.recordingStatus !== ERecordingState.Offline, + replayBufferEnabled: SettingsService.views.values.Output.RecRB, })); function performanceIconClassName() { diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index 6f6cb0743cd9..269df9cd40c1 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -1352,6 +1352,14 @@ export class StreamingService // We received a signal we didn't recognize if (!nextState) return; + if (info.signal === EOBSOutputSignal.Start) { + this.usageStatisticsService.recordFeatureUsage('Recording'); + this.usageStatisticsService.recordAnalyticsEvent('RecordingStatus', { + status: nextState, + code: info.code, + }); + } + if (nextState === ERecordingState.Recording) { const mode = this.views.isDualOutputMode ? 'dual' : 'single'; this.usageStatisticsService.recordFeatureUsage('Recording'); @@ -1364,10 +1372,7 @@ export class StreamingService } if (nextState === ERecordingState.Wrote) { - const fileName = - display === 'vertical' - ? this.contexts.vertical.recording.lastFile() - : this.contexts.horizontal.recording.lastFile(); + const fileName = this.contexts[display].recording.lastFile(); const parsedName = byOS({ [OS.Mac]: fileName, @@ -1390,9 +1395,7 @@ export class StreamingService this.destroyOutputContextIfExists(display, 'streaming'); } - const time = new Date().toISOString(); - this.SET_RECORDING_STATUS(ERecordingState.Offline, display, time); - this.recordingStatusChange.next(ERecordingState.Offline); + this.latestRecordingPath.next(fileName); this.handleV2OutputCode(info); return; @@ -1437,7 +1440,6 @@ export class StreamingService [EOBSOutputSignal.Stopping]: EReplayBufferState.Stopping, [EOBSOutputSignal.Stop]: EReplayBufferState.Offline, } as Dictionary<EReplayBufferState>)[info.signal]; - console.log('handleReplayBufferSignal nextState', nextState, 'signal', info.signal); if (nextState) { const time = new Date().toISOString(); @@ -1459,11 +1461,7 @@ export class StreamingService action: this.jsonrpcService.createRequest(Service.getResourceId(this), 'showHighlighter'), }); - console.log('NodeObs.OBS_service_getLastReplay()', NodeObs.OBS_service_getLastReplay()); - console.log('this.contexts[display].replayBuffer', this.contexts[display].replayBuffer); - console.log('this.contexts[display].recording', this.contexts[display].recording); - - this.replayBufferFileWrite.next(NodeObs.OBS_service_getLastReplay()); + this.replayBufferFileWrite.next(this.contexts[display].replayBuffer.lastFile()); } if (info.signal === EOBSOutputSignal.Stop) { @@ -1488,13 +1486,6 @@ export class StreamingService startReplayBuffer(display: TDisplayType = 'horizontal') { if (this.state.status[display].replayBuffer !== EReplayBufferState.Offline) return; - const mode = this.outputSettingsService.getSettings().mode; - if (!this.contexts[display].recording) return; - - if (this.state.status[display].replayBuffer !== EReplayBufferState.Offline) return; - - this.destroyOutputContextIfExists(display, 'replayBuffer'); - // the replay buffer must have a recording instance to reference if ( this.state.streamingStatus !== EStreamingState.Offline && @@ -1503,12 +1494,14 @@ export class StreamingService this.createRecording(display, 1); } + const mode = this.outputSettingsService.getSettings().mode; + + this.destroyOutputContextIfExists(display, 'replayBuffer'); + if (mode === 'Advanced') { const replayBuffer = AdvancedReplayBufferFactory.create() as IAdvancedReplayBuffer; const recordingSettings = this.outputSettingsService.getRecordingSettings(); - // console.log('Advanced recordingSettings', JSON.stringify(recordingSettings, null, 2)); - replayBuffer.path = recordingSettings.path; replayBuffer.format = recordingSettings.format; replayBuffer.overwrite = recordingSettings.overwrite; @@ -1532,8 +1525,6 @@ export class StreamingService const replayBuffer = SimpleReplayBufferFactory.create() as ISimpleReplayBuffer; const recordingSettings = this.outputSettingsService.getRecordingSettings(); - // console.log('Simple recordingSettings', JSON.stringify(recordingSettings, null, 2)); - replayBuffer.path = recordingSettings.path; replayBuffer.format = recordingSettings.format; replayBuffer.overwrite = recordingSettings.overwrite; @@ -1567,7 +1558,6 @@ export class StreamingService } saveReplay(display: TDisplayType = 'horizontal') { - console.log('this.contexts[display].replayBuffer', this.contexts[display].replayBuffer); if (!this.contexts[display].replayBuffer) return; this.contexts[display].replayBuffer.save(); } @@ -1788,28 +1778,7 @@ export class StreamingService this.clearReconnectingNotification(); } } - // else if (info.type === EOBSOutputType.ReplayBuffer) { - // const nextState: EReplayBufferState = ({ - // [EOBSOutputSignal.Start]: EReplayBufferState.Running, - // [EOBSOutputSignal.Stopping]: EReplayBufferState.Stopping, - // [EOBSOutputSignal.Stop]: EReplayBufferState.Offline, - // [EOBSOutputSignal.Wrote]: EReplayBufferState.Running, - // [EOBSOutputSignal.WriteError]: EReplayBufferState.Running, - // } as Dictionary<EReplayBufferState>)[info.signal]; - - // if (nextState) { - // this.SET_REPLAY_BUFFER_STATUS(nextState, 'horizontal', time); - // this.replayBufferStatusChange.next(nextState); - // } - - // if (info.signal === EOBSOutputSignal.Wrote) { - // this.usageStatisticsService.recordAnalyticsEvent('ReplayBufferStatus', { - // status: 'wrote', - // code: info.code, - // }); - // this.replayBufferFileWrite.next(NodeObs.OBS_service_getLastReplay()); - // } - // } + this.handleV2OutputCode(info); } @@ -2009,66 +1978,7 @@ export class StreamingService this.streamingStatusChange.next(EStreamingState.Live); this.clearReconnectingNotification(); } - } else if (info.type === EOBSOutputType.Recording) { - const nextState: ERecordingState = ({ - [EOBSOutputSignal.Start]: ERecordingState.Recording, - [EOBSOutputSignal.Starting]: ERecordingState.Starting, - [EOBSOutputSignal.Stop]: ERecordingState.Offline, - [EOBSOutputSignal.Stopping]: ERecordingState.Stopping, - [EOBSOutputSignal.Wrote]: ERecordingState.Wrote, - } as Dictionary<ERecordingState>)[info.signal]; - - // We received a signal we didn't recognize - if (!nextState) return; - - if (info.signal === EOBSOutputSignal.Start) { - this.usageStatisticsService.recordFeatureUsage('Recording'); - this.usageStatisticsService.recordAnalyticsEvent('RecordingStatus', { - status: nextState, - code: info.code, - }); - } - - if (info.signal === EOBSOutputSignal.Wrote) { - const filename = NodeObs.OBS_service_getLastRecording(); - const parsedFilename = byOS({ - [OS.Mac]: filename, - [OS.Windows]: filename.replace(/\//, '\\'), - }); - this.recordingModeService.actions.addRecordingEntry(parsedFilename); - this.markersService.actions.exportCsv(parsedFilename); - this.recordingModeService.addRecordingEntry(parsedFilename); - this.latestRecordingPath.next(filename); - // Wrote signals come after Offline, so we return early here - // to not falsely set our state out of Offline - return; - } - - this.SET_RECORDING_STATUS(nextState, 'horizontal', time); - this.recordingStatusChange.next(nextState); } - // else if (info.type === EOBSOutputType.ReplayBuffer) { - // const nextState: EReplayBufferState = ({ - // [EOBSOutputSignal.Start]: EReplayBufferState.Running, - // [EOBSOutputSignal.Stopping]: EReplayBufferState.Stopping, - // [EOBSOutputSignal.Stop]: EReplayBufferState.Offline, - // [EOBSOutputSignal.Wrote]: EReplayBufferState.Running, - // [EOBSOutputSignal.WriteError]: EReplayBufferState.Running, - // } as Dictionary<EReplayBufferState>)[info.signal]; - - // if (nextState) { - // this.SET_REPLAY_BUFFER_STATUS(nextState, 'horizontal', time); - // this.replayBufferStatusChange.next(nextState); - // } - - // if (info.signal === EOBSOutputSignal.Wrote) { - // this.usageStatisticsService.recordAnalyticsEvent('ReplayBufferStatus', { - // status: 'wrote', - // code: info.code, - // }); - // this.replayBufferFileWrite.next(NodeObs.OBS_service_getLastReplay()); - // } - // } if (info.code) { if (this.outputErrorOpen) { From aa2def2c8caa0eb79b792a7b4bdb5833a8146516 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Fri, 7 Mar 2025 16:10:22 -0500 Subject: [PATCH 10/25] Fix Advanced recording. WIP: Replay buffer lag on stop. --- app/components-react/root/StudioFooter.tsx | 8 +- .../platform-apps/api/modules/replay.ts | 4 +- .../settings/output/output-settings.ts | 101 ++++++++- app/services/streaming/streaming-view.ts | 10 +- app/services/streaming/streaming.ts | 200 +++++++++++------- 5 files changed, 229 insertions(+), 94 deletions(-) diff --git a/app/components-react/root/StudioFooter.tsx b/app/components-react/root/StudioFooter.tsx index 3f57fe081afc..e6747c9342fd 100644 --- a/app/components-react/root/StudioFooter.tsx +++ b/app/components-react/root/StudioFooter.tsx @@ -42,9 +42,9 @@ export default function StudioFooterComponent() { StreamingService.views.supports('stream-schedule') && !RecordingModeService.views.isRecordingModeEnabled, streamQuality: PerformanceService.views.streamQuality, - replayBufferOffline: StreamingService.state.replayBufferStatus === EReplayBufferState.Offline, - replayBufferStopping: StreamingService.state.replayBufferStatus === EReplayBufferState.Stopping, - replayBufferSaving: StreamingService.state.replayBufferStatus === EReplayBufferState.Saving, + replayBufferOffline: StreamingService.views.replayBufferStatus === EReplayBufferState.Offline, + replayBufferStopping: StreamingService.views.replayBufferStatus === EReplayBufferState.Stopping, + replayBufferSaving: StreamingService.views.replayBufferStatus === EReplayBufferState.Saving, recordingModeEnabled: RecordingModeService.views.isRecordingModeEnabled, replayBufferEnabled: SettingsService.views.values.Output.RecRB, })); @@ -197,7 +197,7 @@ function RecordingButton() { const { StreamingService } = Services; const { isRecording, recordingStatus } = useVuex(() => ({ isRecording: StreamingService.views.isRecording, - recordingStatus: StreamingService.state.recordingStatus, + recordingStatus: StreamingService.state.status.horizontal.recordingTime, })); function toggleRecording() { diff --git a/app/services/platform-apps/api/modules/replay.ts b/app/services/platform-apps/api/modules/replay.ts index 1d8b58ab3e90..21ed83f8af31 100644 --- a/app/services/platform-apps/api/modules/replay.ts +++ b/app/services/platform-apps/api/modules/replay.ts @@ -107,8 +107,8 @@ export class ReplayModule extends Module { private serializeState(): IReplayBufferState { return { - status: this.streamingService.state.replayBufferStatus, - statusTime: this.streamingService.state.replayBufferStatusTime, + status: this.streamingService.state.status.horizontal.replayBuffer, + statusTime: this.streamingService.state.status.horizontal.replayBufferTime, }; } } diff --git a/app/services/settings/output/output-settings.ts b/app/services/settings/output/output-settings.ts index e9cecdfc29e5..73e971707c10 100644 --- a/app/services/settings/output/output-settings.ts +++ b/app/services/settings/output/output-settings.ts @@ -240,10 +240,6 @@ export class OutputSettingsService extends Service { getRecordingSettings() { const output = this.settingsService.state.Output.formData; const advanced = this.settingsService.state.Advanced.formData; - - console.log('output', JSON.stringify(output, null, 2)); - console.log('advanced', JSON.stringify(advanced, null, 2)); - const mode: TOutputSettingsMode = this.settingsService.findSettingValue( output, 'Untitled', @@ -352,6 +348,103 @@ export class OutputSettingsService extends Service { } } + getReplayBufferSettings() { + const output = this.settingsService.state.Output.formData; + const advanced = this.settingsService.state.Advanced.formData; + + const mode: TOutputSettingsMode = this.settingsService.findSettingValue( + output, + 'Untitled', + 'Mode', + ); + + const pathKey = mode === 'Advanced' ? 'RecFilePath' : 'FilePath'; + const path: string = this.settingsService.findSettingValue(output, 'Recording', pathKey); + + const format: ERecordingFormat = this.settingsService.findValidListValue( + output, + 'Recording', + 'RecFormat', + ) as ERecordingFormat; + + const oldQualityName = this.settingsService.findSettingValue(output, 'Recording', 'RecQuality'); + let quality: ERecordingQuality = ERecordingQuality.HigherQuality; + switch (oldQualityName) { + case 'Small': + quality = ERecordingQuality.HighQuality; + break; + case 'HQ': + quality = ERecordingQuality.HigherQuality; + break; + case 'Lossless': + quality = ERecordingQuality.Lossless; + break; + case 'Stream': + quality = ERecordingQuality.Stream; + break; + } + + const overwrite: boolean = this.settingsService.findSettingValue( + advanced, + 'Recording', + 'OverwriteIfExists', + ); + + const noSpace: boolean = this.settingsService.findSettingValue( + output, + 'Recording', + 'FileNameWithoutSpace', + ); + + const prefix: string = this.settingsService.findSettingValue( + output, + 'Recording', + 'RecRBPrefix', + ); + const suffix: string = this.settingsService.findSettingValue( + output, + 'Recording', + 'RecRBSuffix', + ); + const duration: number = this.settingsService.findSettingValue( + output, + 'Stream Delay', + 'DelaySec', + ); + + const useStreamEncoders = + this.settingsService.findSettingValue(output, 'Recording', 'RecEncoder') === 'none'; + + if (mode === 'Advanced') { + const mixer = this.settingsService.findSettingValue(output, 'Recording', 'RecTracks'); + + // advanced settings + return { + path, + format, + overwrite, + noSpace, + mixer, + useStreamEncoders, + prefix, + suffix, + duration, + }; + } else { + // simple settings + return { + path, + format, + overwrite, + noSpace, + prefix, + suffix, + duration, + useStreamEncoders, + }; + } + } + getSimpleRecordingSettings() { const output = this.settingsService.state.Output.formData; const advanced = this.settingsService.state.Advanced.formData; diff --git a/app/services/streaming/streaming-view.ts b/app/services/streaming/streaming-view.ts index e363ae5459d3..d0b122b9803b 100644 --- a/app/services/streaming/streaming-view.ts +++ b/app/services/streaming/streaming-view.ts @@ -545,11 +545,7 @@ export class StreamInfoView<T extends Object> extends ViewHandler<T> { } get isRecording() { - return this.streamingState.recordingStatus !== ERecordingState.Offline; - } - - get isReplayBufferActive() { - return this.streamingState.replayBufferStatus !== EReplayBufferState.Offline; + return this.streamingState.status.horizontal.recording !== ERecordingState.Offline; } get isIdle(): boolean { @@ -564,4 +560,8 @@ export class StreamInfoView<T extends Object> extends ViewHandler<T> { get selectiveRecording() { return this.streamingState.selectiveRecording; } + + get replayBufferStatus() { + return this.streamingState.status.horizontal.replayBuffer; + } } diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index 269df9cd40c1..eb9a15d9803e 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -915,7 +915,7 @@ export class StreamingService } get isRecording() { - return this.state.recordingStatus !== ERecordingState.Offline; + return this.state.status.horizontal.recording !== ERecordingState.Offline; } get isReplayBufferActive() { @@ -1034,7 +1034,10 @@ export class StreamingService const recordWhenStreaming = this.streamSettingsService.settings.recordWhenStreaming; - if (recordWhenStreaming && this.state.recordingStatus === ERecordingState.Offline) { + if ( + recordWhenStreaming && + this.state.status.horizontal.recording === ERecordingState.Offline + ) { this.toggleRecording(); } } @@ -1141,7 +1144,7 @@ export class StreamingService const keepRecording = this.streamSettingsService.settings.keepRecordingWhenStreamStops; const isRecording = - this.state.recordingStatus === ERecordingState.Recording || + this.state.status.horizontal.recording === ERecordingState.Recording || this.state.status.vertical.recording === ERecordingState.Recording; if (!keepRecording && isRecording) { this.toggleRecording(); @@ -1197,9 +1200,16 @@ export class StreamingService } toggleRecording() { + console.log('this.state.status.horizontal.recording', this.state.status.horizontal.recording); + console.log('this.contexts.horizontal.recording', this.contexts.horizontal.recording); + console.log( + 'this.state.status.horizontal.recording', + this.state.status.horizontal.replayBuffer, + ); + console.log('this.contexts.horizontal.recording', this.contexts.horizontal.replayBuffer); // stop recording if ( - this.state.recordingStatus === ERecordingState.Recording && + this.state.status.horizontal.recording === ERecordingState.Recording && this.state.status.vertical.recording === ERecordingState.Recording ) { // stop recording both displays @@ -1237,7 +1247,7 @@ export class StreamingService this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'vertical', time); this.contexts.vertical.recording.stop(); } else if ( - this.state.recordingStatus === ERecordingState.Recording && + this.state.status.horizontal.recording === ERecordingState.Recording && this.contexts.horizontal.recording !== null ) { const time = new Date().toISOString(); @@ -1248,7 +1258,7 @@ export class StreamingService // start recording if ( - this.state.recordingStatus === ERecordingState.Offline && + this.state.status.horizontal.recording === ERecordingState.Offline && this.state.status.vertical.recording === ERecordingState.Offline ) { if (this.views.isDualOutputMode) { @@ -1268,7 +1278,7 @@ export class StreamingService } } - private createRecording(display: TDisplayType, index: number) { + private createRecording(display: TDisplayType, index: number, skipStart: boolean = false) { this.destroyOutputContextIfExists(display, 'recording'); const mode = this.outputSettingsService.getSettings().mode; @@ -1315,9 +1325,10 @@ export class StreamingService // streaming object // TODO: move to its own function const videoEncoder = recording.videoEncoder; - const stream = - (this.contexts[display].streaming as IAdvancedStreaming) ?? - (AdvancedStreamingFactory.create() as IAdvancedStreaming); + + this.validateOrCreateOutputInstance(mode, display, 'streaming'); + + const stream = this.contexts[display].streaming as IAdvancedStreaming; stream.enforceServiceBitrate = false; stream.enableTwitchVOD = false; stream.audioTrack = index; @@ -1337,6 +1348,12 @@ export class StreamingService // save in state this.contexts[display].recording = recording; + + // The replay buffer requires a recording instance. If the user is streaming but not recording, + // a recording instance still needs to be created but does not need to be started. + if (skipStart) return; + + // start recording this.contexts[display].recording.start(); } @@ -1346,7 +1363,7 @@ export class StreamingService [EOutputSignalState.Start]: ERecordingState.Recording, [EOutputSignalState.Stop]: ERecordingState.Offline, [EOutputSignalState.Stopping]: ERecordingState.Stopping, - [EOutputSignalState.Wrote]: ERecordingState.Wrote, + [EOutputSignalState.Wrote]: ERecordingState.Offline, } as Dictionary<ERecordingState>)[info.signal]; // We received a signal we didn't recognize @@ -1371,7 +1388,7 @@ export class StreamingService }); } - if (nextState === ERecordingState.Wrote) { + if (info.signal === EOutputSignalState.Wrote) { const fileName = this.contexts[display].recording.lastFile(); const parsedName = byOS({ @@ -1396,9 +1413,6 @@ export class StreamingService } this.latestRecordingPath.next(fileName); - - this.handleV2OutputCode(info); - return; } const time = new Date().toISOString(); @@ -1408,9 +1422,12 @@ export class StreamingService this.handleV2OutputCode(info); } - splitFile() { - if (this.state.recordingStatus === ERecordingState.Recording) { - NodeObs.OBS_service_splitFile(); + splitFile(display: TDisplayType = 'horizontal') { + if ( + this.state.status.horizontal.recording === ERecordingState.Recording && + this.contexts[display].recording + ) { + this.contexts[display].recording.splitFile(); } } @@ -1441,12 +1458,6 @@ export class StreamingService [EOBSOutputSignal.Stop]: EReplayBufferState.Offline, } as Dictionary<EReplayBufferState>)[info.signal]; - if (nextState) { - const time = new Date().toISOString(); - this.SET_REPLAY_BUFFER_STATUS(nextState, display, time); - this.replayBufferStatusChange.next(nextState); - } - if (info.signal === EOBSOutputSignal.Wrote) { this.usageStatisticsService.recordAnalyticsEvent('ReplayBufferStatus', { status: 'wrote', @@ -1472,6 +1483,12 @@ export class StreamingService this.destroyOutputContextIfExists(display, 'replayBuffer'); } + + if (nextState) { + const time = new Date().toISOString(); + this.SET_REPLAY_BUFFER_STATUS(nextState, display, time); + this.replayBufferStatusChange.next(nextState); + } } showHighlighter() { @@ -1486,64 +1503,44 @@ export class StreamingService startReplayBuffer(display: TDisplayType = 'horizontal') { if (this.state.status[display].replayBuffer !== EReplayBufferState.Offline) return; - // the replay buffer must have a recording instance to reference - if ( - this.state.streamingStatus !== EStreamingState.Offline && - !this.contexts[display].recording - ) { - this.createRecording(display, 1); - } + this.createReplayBuffer(display); + } + + createReplayBuffer(display: TDisplayType = 'horizontal') { + this.destroyOutputContextIfExists(display, 'replayBuffer'); const mode = this.outputSettingsService.getSettings().mode; - this.destroyOutputContextIfExists(display, 'replayBuffer'); + // A replay buffer requires a recording instance + this.validateOrCreateOutputInstance(mode, display, 'recording'); - if (mode === 'Advanced') { - const replayBuffer = AdvancedReplayBufferFactory.create() as IAdvancedReplayBuffer; - const recordingSettings = this.outputSettingsService.getRecordingSettings(); - - replayBuffer.path = recordingSettings.path; - replayBuffer.format = recordingSettings.format; - replayBuffer.overwrite = recordingSettings.overwrite; - replayBuffer.noSpace = recordingSettings.noSpace; - replayBuffer.duration = recordingSettings.duration; - replayBuffer.video = this.videoSettingsService.contexts[display]; - replayBuffer.prefix = recordingSettings.prefix; - replayBuffer.suffix = recordingSettings.suffix; - replayBuffer.usesStream = recordingSettings.useStreamEncoders; - replayBuffer.mixer = recordingSettings.mixer; - replayBuffer.recording = this.contexts[display].recording as IAdvancedRecording; - replayBuffer.signalHandler = async signal => { - console.log('replay buffer signal', signal); - await this.handleSignal(signal, display); - }; - - this.contexts[display].replayBuffer = replayBuffer; - this.contexts[display].replayBuffer.start(); - this.usageStatisticsService.recordFeatureUsage('ReplayBuffer'); - } else { - const replayBuffer = SimpleReplayBufferFactory.create() as ISimpleReplayBuffer; - const recordingSettings = this.outputSettingsService.getRecordingSettings(); - - replayBuffer.path = recordingSettings.path; - replayBuffer.format = recordingSettings.format; - replayBuffer.overwrite = recordingSettings.overwrite; - replayBuffer.noSpace = recordingSettings.noSpace; - replayBuffer.video = this.videoSettingsService.contexts[display]; - replayBuffer.duration = recordingSettings.duration; - replayBuffer.prefix = recordingSettings.prefix; - replayBuffer.suffix = recordingSettings.suffix; - replayBuffer.usesStream = true; - replayBuffer.recording = this.contexts[display].recording as ISimpleRecording; - replayBuffer.signalHandler = async signal => { - console.log('replay buffer signal', signal); - await this.handleSignal(signal, display); - }; - - this.contexts[display].replayBuffer = replayBuffer; - this.contexts[display].replayBuffer.start(); - this.usageStatisticsService.recordFeatureUsage('ReplayBuffer'); - } + const settings = this.outputSettingsService.getRecordingSettings(); + const replayBuffer = + mode === 'Advanced' + ? (AdvancedReplayBufferFactory.create() as IAdvancedReplayBuffer) + : (SimpleReplayBufferFactory.create() as ISimpleReplayBuffer); + + // assign settings + Object.keys(settings).forEach(key => { + if ((settings as any)[key] === undefined) return; + (replayBuffer as any)[key] = (settings as any)[key]; + }); + + replayBuffer.recording = + mode === 'Advanced' + ? (this.contexts[display].recording as IAdvancedRecording) + : (this.contexts[display].recording as ISimpleRecording); + + this.contexts[display].replayBuffer = replayBuffer; + + this.contexts[display].replayBuffer.video = this.videoSettingsService.contexts[display]; + this.contexts[display].replayBuffer.signalHandler = async signal => { + console.log('replay buffer signal', signal); + await this.handleSignal(signal, display); + }; + + this.contexts[display].replayBuffer.start(); + this.usageStatisticsService.recordFeatureUsage('ReplayBuffer'); } stopReplayBuffer(display: TDisplayType = 'horizontal') { @@ -1554,7 +1551,15 @@ export class StreamingService return; } const forceStop = this.state.status[display].replayBuffer === EReplayBufferState.Stopping; + this.contexts[display].replayBuffer.stop(forceStop); + + // In the case that the user is streaming but not recording, a recording instance + // was created for the replay buffer. If the replay buffer is stopped, the recording + // instance should be destroyed. + if (this.state.status.horizontal.recording === ERecordingState.Offline) { + this.destroyOutputContextIfExists(display, 'recording'); + } } saveReplay(display: TDisplayType = 'horizontal') { @@ -1562,6 +1567,39 @@ export class StreamingService this.contexts[display].replayBuffer.save(); } + validateOrCreateOutputInstance( + mode: 'Simple' | 'Advanced', + display: TDisplayType, + type: 'streaming' | 'recording', + ) { + if (this.contexts[display][type]) { + // Check for a property that only exists on the output type's advanced instance + // Note: the properties below were chosen arbitrarily + const isAdvancedOutputInstance = + type === 'streaming' + ? this.contexts[display][type].hasOwnProperty('rescaling') + : this.contexts[display][type].hasOwnProperty('useStreamEncoders'); + + if ( + (mode === 'Simple' && isAdvancedOutputInstance) || + (mode === 'Advanced' && !isAdvancedOutputInstance) + ) { + this.destroyOutputContextIfExists(display, type); + } else { + return; + } + } + + // Create new instance if it does not exist or was destroyed + if (type === 'streaming') { + // TODO: create streaming instance + this.contexts[display].streaming = + mode === 'Advanced' ? AdvancedStreamingFactory.create() : SimpleStreamingFactory.create(); + } else { + this.createRecording(display, 1, true); + } + } + /** * Show the GoLiveWindow * Prefill fields with data if `prepopulateOptions` provided @@ -1642,7 +1680,7 @@ export class StreamingService get formattedDurationInCurrentRecordingState() { // in dual output mode, if using vertical recording as the second destination // display the vertical recording status time - if (this.state.recordingStatus !== ERecordingState.Offline) { + if (this.state.status.horizontal.recording !== ERecordingState.Offline) { this.formattedDurationSince(moment(this.state.recordingStatusTime)); } else if (this.state.status.vertical.recording !== ERecordingState.Offline) { return this.formattedDurationSince(moment(this.state.status.vertical.recordingTime)); @@ -2223,6 +2261,10 @@ export class StreamingService ) { if (!this.contexts[display] || !this.contexts[display][contextType]) return; + if (this.state.status[display][contextType].toString() !== 'offline') { + this.contexts[display][contextType].stop(); + } + if (this.outputSettingsService.getSettings().mode === 'Advanced') { switch (contextType) { case 'streaming': From aa8cf693d8f35529ef73187c4bd2bfc3d1a9ea2c Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Fri, 7 Mar 2025 17:24:51 -0500 Subject: [PATCH 11/25] Fixed advanced replay buffer. --- app/services/streaming/streaming.ts | 88 +++++++++++++++++++---------- 1 file changed, 59 insertions(+), 29 deletions(-) diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index eb9a15d9803e..0f2a3b90ebf2 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -22,6 +22,7 @@ import { AdvancedStreamingFactory, SimpleStreamingFactory, } from '../../../obs-api'; +import * as obs from '../../../obs-api'; import { Inject } from 'services/core/injector'; import moment from 'moment'; import padStart from 'lodash/padStart'; @@ -1206,7 +1207,6 @@ export class StreamingService 'this.state.status.horizontal.recording', this.state.status.horizontal.replayBuffer, ); - console.log('this.contexts.horizontal.recording', this.contexts.horizontal.replayBuffer); // stop recording if ( this.state.status.horizontal.recording === ERecordingState.Recording && @@ -1243,17 +1243,14 @@ export class StreamingService this.contexts.vertical.recording !== null ) { // stop recording vertical display - const time = new Date().toISOString(); - this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'vertical', time); this.contexts.vertical.recording.stop(); + return; } else if ( this.state.status.horizontal.recording === ERecordingState.Recording && this.contexts.horizontal.recording !== null ) { - const time = new Date().toISOString(); - // stop recording horizontal display - this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'horizontal', time); - this.contexts.horizontal.recording.stop(); + this.contexts.horizontal.recording.stop(true); + return; } // start recording @@ -1301,15 +1298,6 @@ export class StreamingService } }); - // assign context - recording.video = this.videoSettingsService.contexts[display]; - - // set signal handler - recording.signalHandler = async signal => { - console.log('recording signal', signal); - await this.handleSignal(signal, display); - }; - // handle unique properties (including audio) if (mode === 'Advanced') { // output resolutions @@ -1338,6 +1326,10 @@ export class StreamingService stream.outputHeight = ((recording as unknown) as IAdvancedStreaming).outputHeight; stream.video = this.videoSettingsService.contexts[display]; stream.videoEncoder = videoEncoder; + stream.signalHandler = async signal => { + console.log('streaming signal', signal); + await this.handleSignal(signal, display); + }; this.contexts[display].streaming = stream; recording.videoEncoder = videoEncoder; @@ -1347,8 +1339,19 @@ export class StreamingService } // save in state + // this.contexts[display].recording = + // mode === 'Advanced' ? (recording as IAdvancedRecording) : (recording as ISimpleRecording); this.contexts[display].recording = recording; + // assign context + this.contexts[display].recording.video = this.videoSettingsService.contexts[display]; + + // set signal handler + this.contexts[display].recording.signalHandler = async signal => { + console.log('recording signal', signal); + await this.handleSignal(signal, display); + }; + // The replay buffer requires a recording instance. If the user is streaming but not recording, // a recording instance still needs to be created but does not need to be started. if (skipStart) return; @@ -1405,11 +1408,13 @@ export class StreamingService await this.markersService.exportCsv(parsedName); // destroy recording instance - this.destroyOutputContextIfExists(display, 'recording'); + await this.destroyOutputContextIfExists(display, 'recording'); // Also destroy the streaming instance if it was only created for recording // Note: this is only the case when recording without streaming in advanced mode if (this.state.status[display].streaming === EStreamingState.Offline) { - this.destroyOutputContextIfExists(display, 'streaming'); + console.log('destroying streaming instance'); + + await this.destroyOutputContextIfExists(display, 'streaming'); } this.latestRecordingPath.next(fileName); @@ -1438,7 +1443,7 @@ export class StreamingService } if (info.type === EOBSOutputType.ReplayBuffer) { - this.handleReplayBufferSignal(info, display); + await this.handleReplayBufferSignal(info, display); return; } @@ -1448,7 +1453,7 @@ export class StreamingService } } - private handleReplayBufferSignal(info: EOutputSignal, display: TDisplayType) { + private async handleReplayBufferSignal(info: EOutputSignal, display: TDisplayType) { // map signals to status const nextState: EReplayBufferState = ({ [EOBSOutputSignal.Start]: EReplayBufferState.Running, @@ -1481,7 +1486,7 @@ export class StreamingService code: info.code, }); - this.destroyOutputContextIfExists(display, 'replayBuffer'); + await this.destroyOutputContextIfExists(display, 'replayBuffer'); } if (nextState) { @@ -1498,10 +1503,13 @@ export class StreamingService // TODO migrate streaming to new API private handleStreamingSignal(info: EOutputSignal, display: TDisplayType) { // map signals to status + console.log('streaming signal info', info); } startReplayBuffer(display: TDisplayType = 'horizontal') { if (this.state.status[display].replayBuffer !== EReplayBufferState.Offline) return; + // change the replay buffer status for the loading animation + // this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Running, display); this.createReplayBuffer(display); } @@ -1531,7 +1539,10 @@ export class StreamingService ? (this.contexts[display].recording as IAdvancedRecording) : (this.contexts[display].recording as ISimpleRecording); - this.contexts[display].replayBuffer = replayBuffer; + this.contexts[display].replayBuffer = + mode === 'Advanced' + ? (replayBuffer as IAdvancedReplayBuffer) + : (replayBuffer as ISimpleReplayBuffer); this.contexts[display].replayBuffer.video = this.videoSettingsService.contexts[display]; this.contexts[display].replayBuffer.signalHandler = async signal => { @@ -1550,9 +1561,12 @@ export class StreamingService ) { return; } + const forceStop = this.state.status[display].replayBuffer === EReplayBufferState.Stopping; this.contexts[display].replayBuffer.stop(forceStop); + // change the replay buffer status for the loading animation + // this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Stopping, display); // In the case that the user is streaming but not recording, a recording instance // was created for the replay buffer. If the replay buffer is stopped, the recording @@ -1564,10 +1578,12 @@ export class StreamingService saveReplay(display: TDisplayType = 'horizontal') { if (!this.contexts[display].replayBuffer) return; + // change the replay buffer status for the loading animation + // this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Saving, display); this.contexts[display].replayBuffer.save(); } - validateOrCreateOutputInstance( + private validateOrCreateOutputInstance( mode: 'Simple' | 'Advanced', display: TDisplayType, type: 'streaming' | 'recording', @@ -1577,8 +1593,8 @@ export class StreamingService // Note: the properties below were chosen arbitrarily const isAdvancedOutputInstance = type === 'streaming' - ? this.contexts[display][type].hasOwnProperty('rescaling') - : this.contexts[display][type].hasOwnProperty('useStreamEncoders'); + ? this.isAdvancedStreaming(this.contexts[display][type]) + : this.isAdvancedRecording(this.contexts[display][type]); if ( (mode === 'Simple' && isAdvancedOutputInstance) || @@ -1600,6 +1616,14 @@ export class StreamingService } } + isAdvancedStreaming(instance: any): instance is IAdvancedStreaming { + return 'rescaling' in instance; + } + + isAdvancedRecording(instance: any): instance is IAdvancedRecording { + return 'useStreamEncoders' in instance; + } + /** * Show the GoLiveWindow * Prefill fields with data if `prepopulateOptions` provided @@ -2243,8 +2267,8 @@ export class StreamingService */ shutdown() { Object.keys(this.contexts).forEach(display => { - Object.keys(this.contexts[display]).forEach((contextType: keyof IOutputContext) => { - this.destroyOutputContextIfExists(display, contextType); + Object.keys(this.contexts[display]).forEach(async (contextType: keyof IOutputContext) => { + await this.destroyOutputContextIfExists(display, contextType); }); }); } @@ -2254,12 +2278,16 @@ export class StreamingService * @remark Will just return if the context is null * @param display - The display to destroy the output context for * @param contextType - The name of the output context to destroy + * @returns A promise that resolves to true if the context was destroyed, false + * if the context did not exist */ - private destroyOutputContextIfExists( + private async destroyOutputContextIfExists( display: TDisplayType | string, contextType: keyof IOutputContext, ) { - if (!this.contexts[display] || !this.contexts[display][contextType]) return; + if (!this.contexts[display] || !this.contexts[display][contextType]) { + return Promise.resolve(false); + } if (this.state.status[display][contextType].toString() !== 'offline') { this.contexts[display][contextType].stop(); @@ -2300,6 +2328,8 @@ export class StreamingService } this.contexts[display][contextType] = null; + + return Promise.resolve(true); } @mutation() From 32e2d87d6fb1c581f1df6465bcaa26a400b0de99 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Fri, 7 Mar 2025 17:45:39 -0500 Subject: [PATCH 12/25] Remove unnecessary async. --- app/services/streaming/streaming.ts | 50 ++++++++++++++++------------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index 0f2a3b90ebf2..f5296e853e60 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -1201,12 +1201,6 @@ export class StreamingService } toggleRecording() { - console.log('this.state.status.horizontal.recording', this.state.status.horizontal.recording); - console.log('this.contexts.horizontal.recording', this.contexts.horizontal.recording); - console.log( - 'this.state.status.horizontal.recording', - this.state.status.horizontal.replayBuffer, - ); // stop recording if ( this.state.status.horizontal.recording === ERecordingState.Recording && @@ -1243,14 +1237,17 @@ export class StreamingService this.contexts.vertical.recording !== null ) { // stop recording vertical display + // change the recording status for the loading animation + this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'vertical', new Date().toISOString()); this.contexts.vertical.recording.stop(); - return; } else if ( this.state.status.horizontal.recording === ERecordingState.Recording && this.contexts.horizontal.recording !== null ) { + // stop recording horizontal display + // change the recording status for the loading animation + this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'horizontal', new Date().toISOString()); this.contexts.horizontal.recording.stop(true); - return; } // start recording @@ -1339,8 +1336,6 @@ export class StreamingService } // save in state - // this.contexts[display].recording = - // mode === 'Advanced' ? (recording as IAdvancedRecording) : (recording as ISimpleRecording); this.contexts[display].recording = recording; // assign context @@ -1408,13 +1403,11 @@ export class StreamingService await this.markersService.exportCsv(parsedName); // destroy recording instance - await this.destroyOutputContextIfExists(display, 'recording'); + this.destroyOutputContextIfExists(display, 'recording'); // Also destroy the streaming instance if it was only created for recording // Note: this is only the case when recording without streaming in advanced mode if (this.state.status[display].streaming === EStreamingState.Offline) { - console.log('destroying streaming instance'); - - await this.destroyOutputContextIfExists(display, 'streaming'); + this.destroyOutputContextIfExists(display, 'streaming'); } this.latestRecordingPath.next(fileName); @@ -1486,7 +1479,7 @@ export class StreamingService code: info.code, }); - await this.destroyOutputContextIfExists(display, 'replayBuffer'); + this.destroyOutputContextIfExists(display, 'replayBuffer'); } if (nextState) { @@ -1494,6 +1487,8 @@ export class StreamingService this.SET_REPLAY_BUFFER_STATUS(nextState, display, time); this.replayBufferStatusChange.next(nextState); } + + this.handleV2OutputCode(info); } showHighlighter() { @@ -1566,7 +1561,7 @@ export class StreamingService this.contexts[display].replayBuffer.stop(forceStop); // change the replay buffer status for the loading animation - // this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Stopping, display); + this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Stopping, display, new Date().toISOString()); // In the case that the user is streaming but not recording, a recording instance // was created for the replay buffer. If the replay buffer is stopped, the recording @@ -1579,7 +1574,7 @@ export class StreamingService saveReplay(display: TDisplayType = 'horizontal') { if (!this.contexts[display].replayBuffer) return; // change the replay buffer status for the loading animation - // this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Saving, display); + this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Saving, display, new Date().toISOString()); this.contexts[display].replayBuffer.save(); } @@ -2281,16 +2276,27 @@ export class StreamingService * @returns A promise that resolves to true if the context was destroyed, false * if the context did not exist */ - private async destroyOutputContextIfExists( + private destroyOutputContextIfExists( display: TDisplayType | string, contextType: keyof IOutputContext, ) { - if (!this.contexts[display] || !this.contexts[display][contextType]) { - return Promise.resolve(false); - } + if (!this.contexts[display] || !this.contexts[display][contextType]) return; if (this.state.status[display][contextType].toString() !== 'offline') { this.contexts[display][contextType].stop(); + + // change the status to offline for the UI + switch (contextType) { + case 'streaming': + this.state.status[display][contextType] = EStreamingState.Offline; + break; + case 'recording': + this.state.status[display][contextType] = ERecordingState.Offline; + break; + case 'replayBuffer': + this.state.status[display][contextType] = EReplayBufferState.Offline; + break; + } } if (this.outputSettingsService.getSettings().mode === 'Advanced') { @@ -2328,8 +2334,6 @@ export class StreamingService } this.contexts[display][contextType] = null; - - return Promise.resolve(true); } @mutation() From 026d6bb9eb58bcd63268e3959666faf816148902 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Tue, 11 Mar 2025 16:52:11 -0400 Subject: [PATCH 13/25] Move create stream to own function. Fix replay buffer state update issue. Add error handling. --- app/components-react/root/StudioFooter.tsx | 6 +- app/i18n/en-US/streaming.json | 3 +- app/services/highlighter/index.ts | 14 + .../settings/output/output-settings.ts | 83 ++ app/services/streaming/stream-error.ts | 7 +- app/services/streaming/streaming-view.ts | 1 - app/services/streaming/streaming.ts | 788 ++++++++---------- 7 files changed, 472 insertions(+), 430 deletions(-) diff --git a/app/components-react/root/StudioFooter.tsx b/app/components-react/root/StudioFooter.tsx index e6747c9342fd..b9ba49ddfeb0 100644 --- a/app/components-react/root/StudioFooter.tsx +++ b/app/components-react/root/StudioFooter.tsx @@ -43,8 +43,8 @@ export default function StudioFooterComponent() { !RecordingModeService.views.isRecordingModeEnabled, streamQuality: PerformanceService.views.streamQuality, replayBufferOffline: StreamingService.views.replayBufferStatus === EReplayBufferState.Offline, - replayBufferStopping: StreamingService.views.replayBufferStatus === EReplayBufferState.Stopping, - replayBufferSaving: StreamingService.views.replayBufferStatus === EReplayBufferState.Saving, + replayBufferStopping: StreamingService.state.replayBufferStatus === EReplayBufferState.Stopping, + replayBufferSaving: StreamingService.state.replayBufferStatus === EReplayBufferState.Saving, recordingModeEnabled: RecordingModeService.views.isRecordingModeEnabled, replayBufferEnabled: SettingsService.views.values.Output.RecRB, })); @@ -83,7 +83,7 @@ export default function StudioFooterComponent() { } function toggleReplayBuffer() { - if (StreamingService.state.replayBufferStatus === EReplayBufferState.Offline) { + if (replayBufferOffline) { StreamingService.actions.startReplayBuffer(); } else { StreamingService.actions.stopReplayBuffer(); diff --git a/app/i18n/en-US/streaming.json b/app/i18n/en-US/streaming.json index 625039fd835a..a95e176685f4 100644 --- a/app/i18n/en-US/streaming.json +++ b/app/i18n/en-US/streaming.json @@ -289,5 +289,6 @@ "Issues": "Issues", "Multistream Error": "Multistream Error", "One of destinations might have incomplete permissions. Reconnect the destinations in settings and try again.": "One of destinations might have incomplete permissions. Reconnect the destinations in settings and try again.", - "A new Highlight has been saved. Click to edit in the Highlighter": "A new Highlight has been saved. Click to edit in the Highlighter" + "A new Highlight has been saved. Click to edit in the Highlighter": "A new Highlight has been saved. Click to edit in the Highlighter", + "An unknown %{type} error occurred.": "An unknown %{type} error occurred." } diff --git a/app/services/highlighter/index.ts b/app/services/highlighter/index.ts index 414d58cd1198..e74ce62dd3db 100644 --- a/app/services/highlighter/index.ts +++ b/app/services/highlighter/index.ts @@ -352,6 +352,16 @@ export class HighlighterService extends PersistentStatefulService<IHighlighterSt } this.handleStreamingChanges(); + + this.streamingService.replayBufferFileWrite.subscribe(async clipPath => { + const message = $t('A new Highlight has been saved. Click to edit in the Highlighter'); + + this.notificationsService.actions.push({ + type: ENotificationType.SUCCESS, + message, + action: this.jsonrpcService.createRequest(Service.getResourceId(this), 'showHighlighter'), + }); + }); } private handleStreamingChanges() { @@ -1490,4 +1500,8 @@ export class HighlighterService extends PersistentStatefulService<IHighlighterSt return id; } + + showHighlighter() { + this.navigationService.navigate('Highlighter'); + } } diff --git a/app/services/settings/output/output-settings.ts b/app/services/settings/output/output-settings.ts index 73e971707c10..1dee9b443eb2 100644 --- a/app/services/settings/output/output-settings.ts +++ b/app/services/settings/output/output-settings.ts @@ -230,6 +230,89 @@ export class OutputSettingsService extends Service { }; } + /** + * Get recording settings + * @remark Primarily used for setting up the recording output context, + * this function will automatically return either the simple or advanced + * settings based on the current mode. + * @returns settings for the recording + */ + getStreamingSettings() { + const output = this.settingsService.state.Output.formData; + + const mode: TOutputSettingsMode = this.settingsService.findSettingValue( + output, + 'Untitled', + 'Mode', + ); + + const oldQualityName = this.settingsService.findSettingValue(output, 'Recording', 'RecQuality'); + let quality: ERecordingQuality = ERecordingQuality.HigherQuality; + switch (oldQualityName) { + case 'Small': + quality = ERecordingQuality.HighQuality; + break; + case 'HQ': + quality = ERecordingQuality.HigherQuality; + break; + case 'Lossless': + quality = ERecordingQuality.Lossless; + break; + case 'Stream': + quality = ERecordingQuality.Stream; + break; + } + + const videoEncoder = obsEncoderToEncoderFamily( + this.settingsService.findSettingValue(output, 'Streaming', 'Encoder') || + this.settingsService.findSettingValue(output, 'Streaming', 'StreamEncoder'), + ) as EEncoderFamily; + + const enforceBitrateKey = mode === 'Advanced' ? 'ApplyServiceSettings' : 'EnforceBitrate'; + const enforceServiceBitrate = this.settingsService.findSettingValue( + output, + 'Streaming', + enforceBitrateKey, + ); + + const enableTwitchVOD = this.settingsService.findSettingValue( + output, + 'Streaming', + 'VodTrackEnabled', + ); + + const useAdvanced = this.settingsService.findSettingValue(output, 'Streaming', 'UseAdvanced'); + console.log('useAdvanced', useAdvanced); + + const customEncSettings = this.settingsService.findSettingValue( + output, + 'Streaming', + 'x264Settings', + ); + + const rescaling = this.settingsService.findSettingValue(output, 'Recording', 'RecRescale'); + + if (mode === 'Advanced') { + const twitchTrack = 3; // 3 in the tests, 2 in the description + + return { + videoEncoder, + enforceServiceBitrate, + enableTwitchVOD, + twitchTrack, + rescaling, + }; + } else { + return { + videoEncoder, + enforceServiceBitrate, + enableTwitchVOD, + useAdvanced, + customEncSettings, + }; + } + } + /** * Get recording settings * @remark Primarily used for setting up the recording output context, diff --git a/app/services/streaming/stream-error.ts b/app/services/streaming/stream-error.ts index 4658ccef5086..1e67549516d0 100644 --- a/app/services/streaming/stream-error.ts +++ b/app/services/streaming/stream-error.ts @@ -376,10 +376,11 @@ export function formatUnknownErrorMessage( } catch (error: unknown) { // if it's not JSON, it is the message itself // don't show blocked message to user - if (!info.split(' ').includes('blocked')) { + if (info.split(' ').includes('blocked')) { + messages.user.push(errorTypes['UNKNOWN_STREAMING_ERROR_WITH_MESSAGE'].message); messages.user.push(info); } else { - messages.user.push(errorTypes['UNKNOWN_STREAMING_ERROR_WITH_MESSAGE'].message); + messages.user.push(info); } // always add non-JSON info to diag report @@ -393,7 +394,7 @@ export function formatUnknownErrorMessage( let error; let platform; - if (typeof info.error === 'string') { + if (typeof info.error === 'string' && info.error !== '') { /* * Try to parse error as JSON as originally done, however, if it's just a string * (such as in the case of invalid path and many other unknown -4 errors we've diff --git a/app/services/streaming/streaming-view.ts b/app/services/streaming/streaming-view.ts index d0b122b9803b..04ace797f528 100644 --- a/app/services/streaming/streaming-view.ts +++ b/app/services/streaming/streaming-view.ts @@ -4,7 +4,6 @@ import { IStreamSettings, EStreamingState, ERecordingState, - EReplayBufferState, } from './streaming-api'; import { StreamSettingsService, ICustomStreamDestination } from '../settings/streaming'; import { UserService } from '../user'; diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index f5296e853e60..66ba1794b6ac 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -1,6 +1,5 @@ import Vue from 'vue'; import { mutation, StatefulService } from 'services/core/stateful-service'; -import { Service } from 'services/core/service'; import { AdvancedRecordingFactory, AudioEncoderFactory, @@ -21,8 +20,11 @@ import { IAdvancedStreaming, AdvancedStreamingFactory, SimpleStreamingFactory, + ServiceFactory, + DelayFactory, + ReconnectFactory, + NetworkFactory, } from '../../../obs-api'; -import * as obs from '../../../obs-api'; import { Inject } from 'services/core/injector'; import moment from 'moment'; import padStart from 'lodash/padStart'; @@ -78,8 +80,6 @@ import { byOS, OS } from 'util/operating-systems'; import { DualOutputService } from 'services/dual-output'; import { capitalize } from 'lodash'; import { YoutubeService } from 'app-services'; -import { JsonrpcService } from '../api/jsonrpc'; -import { NavigationService } from 'services/navigation'; enum EOBSOutputType { Streaming = 'streaming', @@ -87,6 +87,13 @@ enum EOBSOutputType { ReplayBuffer = 'replay-buffer', } +const outputType = (type: EOBSOutputType) => + ({ + [EOBSOutputType.Streaming]: $t('Streaming'), + [EOBSOutputType.Recording]: $t('Recording'), + [EOBSOutputType.ReplayBuffer]: $t('Replay Buffer'), + }[type]); + enum EOBSOutputSignal { Starting = 'starting', Start = 'start', @@ -148,8 +155,6 @@ export class StreamingService @Inject() private markersService: MarkersService; @Inject() private dualOutputService: DualOutputService; @Inject() private youtubeService: YoutubeService; - @Inject() private jsonrpcService: JsonrpcService; - @Inject() private navigationService: NavigationService; streamingStatusChange = new Subject<EStreamingState>(); recordingStatusChange = new Subject<ERecordingState>(); @@ -242,7 +247,7 @@ export class StreamingService init() { NodeObs.OBS_service_connectOutputSignals((info: IOBSOutputSignalInfo) => { this.signalInfoChanged.next(info); - this.handleOBSV2OutputSignal(info); + this.handleOBSOutputSignal(info); }); // watch for StreamInfoView at emit `streamInfoChanged` event if something has been hanged there @@ -1272,6 +1277,12 @@ export class StreamingService } } + /** + * Create a recording instance for the given display + * @param display - The display to create the recording for + * @param index - The index of the audio track + * @param skipStart - Whether to skip starting the recording. This is used when creating a recording instance for the replay buffer + */ private createRecording(display: TDisplayType, index: number, skipStart: boolean = false) { this.destroyOutputContextIfExists(display, 'recording'); @@ -1288,8 +1299,11 @@ export class StreamingService Object.keys(settings).forEach(key => { if ((settings as any)[key] === undefined) return; + // share the video encoder with the streaming instance if it exists if (key === 'videoEncoder') { - recording.videoEncoder = VideoEncoderFactory.create(settings.videoEncoder, 'video-encoder'); + recording.videoEncoder = + this.contexts[display].streaming?.videoEncoder ?? + VideoEncoderFactory.create(settings.videoEncoder, 'video-encoder'); } else { (recording as any)[key] = (settings as any)[key]; } @@ -1303,34 +1317,12 @@ export class StreamingService (recording as IAdvancedRecording).outputHeight = resolution.outputHeight; // audio track - const trackName = `track${index}`; - const track = AudioTrackFactory.create(160, trackName); - AudioTrackFactory.setAtIndex(track, index); - - // streaming object - // TODO: move to its own function - const videoEncoder = recording.videoEncoder; + this.createAudioTrack(index); + // streaming instance this.validateOrCreateOutputInstance(mode, display, 'streaming'); - - const stream = this.contexts[display].streaming as IAdvancedStreaming; - stream.enforceServiceBitrate = false; - stream.enableTwitchVOD = false; - stream.audioTrack = index; - // stream.twitchTrack = 3; - stream.rescaling = ((recording as unknown) as IAdvancedStreaming).rescaling; - stream.outputWidth = ((recording as unknown) as IAdvancedStreaming).outputWidth; - stream.outputHeight = ((recording as unknown) as IAdvancedStreaming).outputHeight; - stream.video = this.videoSettingsService.contexts[display]; - stream.videoEncoder = videoEncoder; - stream.signalHandler = async signal => { - console.log('streaming signal', signal); - await this.handleSignal(signal, display); - }; - - this.contexts[display].streaming = stream; - recording.videoEncoder = videoEncoder; - recording.streaming = stream; + (recording as IAdvancedRecording).streaming = this.contexts[display] + .streaming as IAdvancedStreaming; } else { (recording as ISimpleRecording).audioEncoder = AudioEncoderFactory.create(); } @@ -1355,6 +1347,130 @@ export class StreamingService this.contexts[display].recording.start(); } + /** + * Create a streaming instance for the given display + * @param display - The display to create the streaming for + * @param index - The index of the audio track + * @param skipStart - Whether to skip starting the streaming. This is used when creating a streaming instance for advanced recording + */ + private createStreaming(display: TDisplayType, index: number, skipStart: boolean = false) { + this.destroyOutputContextIfExists(display, 'streaming'); + + const mode = this.outputSettingsService.getSettings().mode; + + const settings = this.outputSettingsService.getStreamingSettings(); + + console.log('createStreaming settings', settings); + + const stream = + mode === 'Advanced' + ? (AdvancedStreamingFactory.create() as IAdvancedStreaming) + : (SimpleStreamingFactory.create() as ISimpleStreaming); + + // assign settings + Object.keys(settings).forEach(key => { + if ((settings as any)[key] === undefined) return; + + // share the video encoder with the recording instance if it exists + if (key === 'videoEncoder') { + stream.videoEncoder = + this.contexts[display].recording?.videoEncoder ?? + VideoEncoderFactory.create(settings.videoEncoder, 'video-encoder'); + } else { + (stream as any)[key] = (settings as any)[key]; + } + }); + + if (this.isAdvancedStreaming(stream)) { + const resolution = this.videoSettingsService.outputResolutions[display]; + stream.outputWidth = resolution.outputWidth; + stream.outputHeight = resolution.outputHeight; + + // stream audio track + this.createAudioTrack(index); + stream.audioTrack = index; + + // Twitch VOD audio track + if (stream.enableTwitchVOD && stream.twitchTrack) { + this.createAudioTrack(stream.twitchTrack); + } else if (stream.enableTwitchVOD) { + // do not use the same audio track for the VOD as the stream + stream.twitchTrack = index + 1; + this.createAudioTrack(stream.twitchTrack); + } + } else { + stream.audioEncoder = + (this.contexts[display].recording as ISimpleRecording)?.audioEncoder ?? + AudioEncoderFactory.create(); + } + + stream.video = this.videoSettingsService.contexts[display]; + stream.signalHandler = async signal => { + console.log('streaming signal', signal); + await this.handleSignal(signal, display); + }; + + this.contexts[display].streaming = stream; + + if (skipStart) return; + + // TODO: fully implement streaming + this.contexts[display].streaming.service = ServiceFactory.legacySettings; + this.contexts[display].streaming.delay = DelayFactory.create(); + this.contexts[display].streaming.reconnect = ReconnectFactory.create(); + this.contexts[display].streaming.network = NetworkFactory.create(); + + this.contexts[display].streaming.start(); + } + + /** + * Signal handler for the Factory API for streaming, recording, and replay buffer + * @param info - The signal info + * @param display - The context to handle the signal for + */ + private async handleSignal(info: EOutputSignal, display: TDisplayType) { + if (info.code !== EOutputCode.Success) { + // handle errors before attempting anything else + console.error('Output Signal Error:', info); + + if (!info.error || info.error === '') { + info.error = $t('An unknown %{type} error occurred.', { + type: outputType(info.type as EOBSOutputType), + }); + } + + this.handleFactoryOutputError(info, display); + } else if (info.type === EOBSOutputType.Streaming) { + this.handleStreamingSignal(info, display); + } else if (info.type === EOBSOutputType.Recording) { + await this.handleRecordingSignal(info, display); + } else if (info.type === EOBSOutputType.ReplayBuffer) { + await this.handleReplayBufferSignal(info, display); + } else { + console.debug('Unknown Output Signal or Error:', info); + } + } + + private handleStreamingSignal(info: EOutputSignal, display: TDisplayType) { + // map signals to status + console.log('streaming signal info', info); + + // const nextState: EStreamingState = ({ + // [EOBSOutputSignal.Start]: EStreamingState.Starting, + // [EOBSOutputSignal.Stop]: EStreamingState.Offline, + // [EOBSOutputSignal.Stopping]: EStreamingState.Ending, + // [EOBSOutputSignal.Deactivate]: EStreamingState.Offline, + // } as Dictionary<EStreamingState>)[info.signal]; + + // EOBSOutputSignal.Starting; + // EOBSOutputSignal.Activate; + // EOBSOutputSignal.Start; + // EOBSOutputSignal.Stopping; + // EOBSOutputSignal.Stop; + + this.handleFactoryOutputError(info, display); + } + private async handleRecordingSignal(info: EOutputSignal, display: TDisplayType) { // map signals to status const nextState: ERecordingState = ({ @@ -1416,34 +1532,6 @@ export class StreamingService const time = new Date().toISOString(); this.SET_RECORDING_STATUS(nextState, display, time); this.recordingStatusChange.next(nextState); - - this.handleV2OutputCode(info); - } - - splitFile(display: TDisplayType = 'horizontal') { - if ( - this.state.status.horizontal.recording === ERecordingState.Recording && - this.contexts[display].recording - ) { - this.contexts[display].recording.splitFile(); - } - } - - private async handleSignal(info: EOutputSignal, display: TDisplayType) { - if (info.type === EOBSOutputType.Recording) { - await this.handleRecordingSignal(info, display); - return; - } - - if (info.type === EOBSOutputType.ReplayBuffer) { - await this.handleReplayBufferSignal(info, display); - return; - } - - if (info.type === EOBSOutputType.Streaming) { - this.handleStreamingSignal(info, display); - return; - } } private async handleReplayBufferSignal(info: EOutputSignal, display: TDisplayType) { @@ -1456,20 +1544,19 @@ export class StreamingService [EOBSOutputSignal.Stop]: EReplayBufferState.Offline, } as Dictionary<EReplayBufferState>)[info.signal]; + // We received a signal we didn't recognize + if (!nextState) return; + + const time = new Date().toISOString(); + this.SET_REPLAY_BUFFER_STATUS(nextState, display, time); + this.replayBufferStatusChange.next(nextState); + if (info.signal === EOBSOutputSignal.Wrote) { this.usageStatisticsService.recordAnalyticsEvent('ReplayBufferStatus', { status: 'wrote', code: info.code, }); - const message = $t('A new Highlight has been saved. Click to edit in the Highlighter'); - - this.notificationsService.actions.push({ - type: ENotificationType.SUCCESS, - message, - action: this.jsonrpcService.createRequest(Service.getResourceId(this), 'showHighlighter'), - }); - this.replayBufferFileWrite.next(this.contexts[display].replayBuffer.lastFile()); } @@ -1479,27 +1566,27 @@ export class StreamingService code: info.code, }); - this.destroyOutputContextIfExists(display, 'replayBuffer'); - } + // In the case that the user is streaming but not recording, a recording instance + // was created for the replay buffer. If the replay buffer is stopped, the recording + // instance should be destroyed. + if (this.state.status.horizontal.recording === ERecordingState.Offline) { + this.destroyOutputContextIfExists(display, 'recording'); + } - if (nextState) { - const time = new Date().toISOString(); - this.SET_REPLAY_BUFFER_STATUS(nextState, display, time); - this.replayBufferStatusChange.next(nextState); + this.destroyOutputContextIfExists(display, 'replayBuffer'); } - - this.handleV2OutputCode(info); } - showHighlighter() { - this.navigationService.navigate('Highlighter'); + splitFile(display: TDisplayType = 'horizontal') { + if ( + this.state.status.horizontal.recording === ERecordingState.Recording && + this.contexts[display].recording + ) { + this.contexts[display].recording.splitFile(); + } } // TODO migrate streaming to new API - private handleStreamingSignal(info: EOutputSignal, display: TDisplayType) { - // map signals to status - console.log('streaming signal info', info); - } startReplayBuffer(display: TDisplayType = 'horizontal') { if (this.state.status[display].replayBuffer !== EReplayBufferState.Offline) return; @@ -1562,13 +1649,6 @@ export class StreamingService this.contexts[display].replayBuffer.stop(forceStop); // change the replay buffer status for the loading animation this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Stopping, display, new Date().toISOString()); - - // In the case that the user is streaming but not recording, a recording instance - // was created for the replay buffer. If the replay buffer is stopped, the recording - // instance should be destroyed. - if (this.state.status.horizontal.recording === ERecordingState.Offline) { - this.destroyOutputContextIfExists(display, 'recording'); - } } saveReplay(display: TDisplayType = 'horizontal') { @@ -1596,21 +1676,28 @@ export class StreamingService (mode === 'Advanced' && !isAdvancedOutputInstance) ) { this.destroyOutputContextIfExists(display, type); - } else { - return; } } // Create new instance if it does not exist or was destroyed if (type === 'streaming') { // TODO: create streaming instance - this.contexts[display].streaming = - mode === 'Advanced' ? AdvancedStreamingFactory.create() : SimpleStreamingFactory.create(); + this.createStreaming(display, 1, true); } else { this.createRecording(display, 1, true); } } + /** + * Create an audio track + * @param index - index of the audio track to create + */ + createAudioTrack(index: number) { + const trackName = `track${index}`; + const track = AudioTrackFactory.create(160, trackName); + AudioTrackFactory.setAtIndex(track, index); + } + isAdvancedStreaming(instance: any): instance is IAdvancedStreaming { return 'rescaling' in instance; } @@ -1619,6 +1706,18 @@ export class StreamingService return 'useStreamEncoders' in instance; } + handleFactoryOutputError(info: EOutputSignal, display: TDisplayType) { + const legacyInfo = { + type: info.type as EOBSOutputType, + signal: info.signal as EOBSOutputSignal, + code: info.code as EOutputCode, + error: info.error, + service: display as string, + } as IOBSOutputSignalInfo; + + this.handleOBSOutputError(legacyInfo); + } + /** * Show the GoLiveWindow * Prefill fields with data if `prepopulateOptions` provided @@ -1751,7 +1850,7 @@ export class StreamingService private streamErrorUserMessage = ''; private streamErrorReportMessage = ''; - private handleOBSV2OutputSignal(info: IOBSOutputSignalInfo) { + private handleOBSOutputSignal(info: IOBSOutputSignalInfo) { console.debug('OBS Output signal: ', info); /* @@ -1761,8 +1860,10 @@ export class StreamingService * - Dual output mode: when vertical display is second destination, * resolve after horizontal stream started */ + const display = this.convertSignalToDisplay(info.service); + const isVerticalDisplayStartSignal = - info.service === 'vertical' && info.signal === EOBSOutputSignal.Start; + display === 'vertical' && info.signal === EOBSOutputSignal.Start; const shouldResolve = !this.views.isDualOutputMode || @@ -1773,7 +1874,7 @@ export class StreamingService if (info.type === EOBSOutputType.Streaming) { if (info.signal === EOBSOutputSignal.Start && shouldResolve) { - this.SET_STREAMING_STATUS(EStreamingState.Live, time); + this.SET_STREAMING_STATUS(EStreamingState.Live, display, time); this.resolveStartStreaming(); this.streamingStatusChange.next(EStreamingState.Live); @@ -1810,10 +1911,10 @@ export class StreamingService }); this.usageStatisticsService.recordFeatureUsage('Streaming'); } else if (info.signal === EOBSOutputSignal.Starting && shouldResolve) { - this.SET_STREAMING_STATUS(EStreamingState.Starting, time); + this.SET_STREAMING_STATUS(EStreamingState.Starting, display, time); this.streamingStatusChange.next(EStreamingState.Starting); } else if (info.signal === EOBSOutputSignal.Stop) { - this.SET_STREAMING_STATUS(EStreamingState.Offline, time); + this.SET_STREAMING_STATUS(EStreamingState.Offline, display, time); this.RESET_STREAM_INFO(); this.rejectStartStreaming(); this.streamingStatusChange.next(EStreamingState.Offline); @@ -1823,223 +1924,125 @@ export class StreamingService }); } else if (info.signal === EOBSOutputSignal.Stopping) { this.sendStreamEndEvent(); - this.SET_STREAMING_STATUS(EStreamingState.Ending, time); + this.SET_STREAMING_STATUS(EStreamingState.Ending, display, time); this.streamingStatusChange.next(EStreamingState.Ending); } else if (info.signal === EOBSOutputSignal.Reconnect) { - this.SET_STREAMING_STATUS(EStreamingState.Reconnecting); + this.SET_STREAMING_STATUS(EStreamingState.Reconnecting, display); this.streamingStatusChange.next(EStreamingState.Reconnecting); this.sendReconnectingNotification(); } else if (info.signal === EOBSOutputSignal.ReconnectSuccess) { - this.SET_STREAMING_STATUS(EStreamingState.Live); + this.SET_STREAMING_STATUS(EStreamingState.Live, display); this.streamingStatusChange.next(EStreamingState.Live); this.clearReconnectingNotification(); } } - this.handleV2OutputCode(info); + if (info.code === EOutputCode.Success) return; + this.handleOBSOutputError(info); } - private handleV2OutputCode(info: IOBSOutputSignalInfo | EOutputSignal) { - if (info.code) { - if (this.outputErrorOpen) { - console.warn('Not showing error message because existing window is open.', info); - return; - } - - let errorText = ''; - let extendedErrorText = ''; - let linkToDriverInfo = false; - let showNativeErrorMessage = false; - - if (info.code === EOutputCode.BadPath) { - errorText = $t( - 'Invalid Path or Connection URL. Please check your settings to confirm that they are valid.', - ); - } else if (info.code === EOutputCode.ConnectFailed) { - errorText = $t( - 'Failed to connect to the streaming server. Please check your internet connection.', - ); - } else if (info.code === EOutputCode.Disconnected) { - errorText = $t( - 'Disconnected from the streaming server. Please check your internet connection.', - ); - } else if (info.code === EOutputCode.InvalidStream) { - errorText = $t( - 'Could not access the specified channel or stream key. Please log out and back in to refresh your credentials. If the problem persists, there may be a problem connecting to the server.', - ); - } else if (info.code === EOutputCode.NoSpace) { - errorText = $t('There is not sufficient disk space to continue recording.'); - } else if (info.code === EOutputCode.Unsupported) { - errorText = - $t( - 'The output format is either unsupported or does not support more than one audio track. ', - ) + $t('Please check your settings and try again.'); - } else if (info.code === EOutputCode.OutdatedDriver) { - linkToDriverInfo = true; - errorText = $t( - 'An error occurred with the output. This is usually caused by out of date video drivers. Please ensure your Nvidia or AMD drivers are up to date and try again.', - ); - } else { - // -4 is used for generic unknown messages in OBS. Both -4 and any other code - // we don't recognize should fall into this branch and show a generic error. - errorText = $t( - 'An error occurred with the output. Please check your streaming and recording settings.', - ); - if (info.error) { - showNativeErrorMessage = true; - extendedErrorText = errorText + '\n\n' + $t('System error message:') + info.error + '"'; - } - } - const buttons = [$t('OK')]; - - const title = { - [EOBSOutputType.Streaming]: $t('Streaming Error'), - [EOBSOutputType.Recording]: $t('Recording Error'), - [EOBSOutputType.ReplayBuffer]: $t('Replay Buffer Error'), - }[info.type]; - - if (linkToDriverInfo) buttons.push($t('Learn More')); - if (showNativeErrorMessage) buttons.push($t('More')); - - this.outputErrorOpen = true; - const errorType = 'error'; - remote.dialog - .showMessageBox(Utils.getMainWindow(), { - buttons, - title, - type: errorType, - message: errorText, - }) - .then(({ response }) => { - if (linkToDriverInfo && response === 1) { - this.outputErrorOpen = false; - remote.shell.openExternal( - 'https://howto.streamlabs.com/streamlabs-obs-19/nvidia-graphics-driver-clean-install-tutorial-7000', - ); - } else { - let expectedResponse = 1; - if (linkToDriverInfo) { - expectedResponse = 2; - } - if (showNativeErrorMessage && response === expectedResponse) { - const buttons = [$t('OK')]; - remote.dialog - .showMessageBox({ - buttons, - title, - type: errorType, - message: extendedErrorText, - }) - .then(({ response }) => { - this.outputErrorOpen = false; - }) - .catch(() => { - this.outputErrorOpen = false; - }); - } else { - this.outputErrorOpen = false; - } - } - }) - .catch(() => { - this.outputErrorOpen = false; - }); - this.windowsService.actions.closeChildWindow(); + /** + * Convert the signal from IOBSOutputSignalInfo to the display type + * @remark This is required to facilitate special handling for each display in dual output mode + * @param service - String representing the name of the service returned from the API + * @returns - The display type + */ + private convertSignalToDisplay(service: string): TDisplayType { + switch (service) { + case 'vertical': + return 'vertical'; + case 'horizontal': + return 'horizontal'; + case 'default': + return 'horizontal'; + default: + return 'horizontal'; } } - private handleOBSOutputSignal(info: IOBSOutputSignalInfo) { + private handleOBSOutputError(info: IOBSOutputSignalInfo) { console.debug('OBS Output signal: ', info); - const shouldResolve = - !this.views.isDualOutputMode || (this.views.isDualOutputMode && info.service === 'vertical'); + if (!info.code) return; + if ((info.code as EOutputCode) === EOutputCode.Success) return; - const time = new Date().toISOString(); + if (this.outputErrorOpen) { + console.warn('Not showing error message because existing window is open.', info); - if (info.type === EOBSOutputType.Streaming) { - if (info.signal === EOBSOutputSignal.Start && shouldResolve) { - this.SET_STREAMING_STATUS(EStreamingState.Live, time); - this.resolveStartStreaming(); - this.streamingStatusChange.next(EStreamingState.Live); + const messages = formatUnknownErrorMessage( + info, + this.streamErrorUserMessage, + this.streamErrorReportMessage, + ); - let streamEncoderInfo: Partial<IOutputSettings> = {}; - let game: string = ''; + this.streamErrorCreated.next(messages.report); - try { - streamEncoderInfo = this.outputSettingsService.getSettings(); - game = this.views.game; - } catch (e: unknown) { - console.error('Error fetching stream encoder info: ', e); - } - - const eventMetadata: Dictionary<any> = { - ...streamEncoderInfo, - game, - }; + return; + } - if (this.videoEncodingOptimizationService.state.useOptimizedProfile) { - eventMetadata.useOptimizedProfile = true; - } + let errorText = this.streamErrorUserMessage; + let details = ''; + let linkToDriverInfo = false; + let showNativeErrorMessage = false; + let diagReportMessage = this.streamErrorUserMessage; - const streamSettings = this.streamSettingsService.settings; + if (info.code === EOutputCode.BadPath) { + errorText = $t( + 'Invalid Path or Connection URL. Please check your settings to confirm that they are valid.', + ); + diagReportMessage = diagReportMessage.concat(errorText); + } else if (info.code === EOutputCode.ConnectFailed) { + errorText = $t( + 'Failed to connect to the streaming server. Please check your internet connection.', + ); + diagReportMessage = diagReportMessage.concat(errorText); + } else if (info.code === EOutputCode.Disconnected) { + errorText = $t( + 'Disconnected from the streaming server. Please check your internet connection.', + ); + diagReportMessage = diagReportMessage.concat(errorText); + } else if (info.code === EOutputCode.InvalidStream) { + errorText = $t( + 'Could not access the specified channel or stream key. Please log out and back in to refresh your credentials. If the problem persists, there may be a problem connecting to the server.', + ); + diagReportMessage = diagReportMessage.concat(errorText); + } else if (info.code === EOutputCode.NoSpace) { + errorText = $t('There is not sufficient disk space to continue recording.'); + diagReportMessage = diagReportMessage.concat(errorText); + } else if (info.code === EOutputCode.Unsupported) { + errorText = + $t( + 'The output format is either unsupported or does not support more than one audio track. ', + ) + $t('Please check your settings and try again.'); + diagReportMessage = diagReportMessage.concat(errorText); + } else if (info.code === EOutputCode.OutdatedDriver) { + linkToDriverInfo = true; + errorText = $t( + 'An error occurred with the output. This is usually caused by out of date video drivers. Please ensure your Nvidia or AMD drivers are up to date and try again.', + ); + diagReportMessage = diagReportMessage.concat(errorText); + } else { + // -4 is used for generic unknown messages in OBS. Both -4 and any other code + // we don't recognize should fall into this branch and show a generic error. - eventMetadata.streamType = streamSettings.streamType; - eventMetadata.platform = streamSettings.platform; - eventMetadata.server = streamSettings.server; - eventMetadata.outputMode = this.views.isDualOutputMode ? 'dual' : 'single'; - eventMetadata.platforms = this.views.protectedModeEnabled - ? [ - ...this.views.enabledPlatforms, - /* - * This is to be consistent with `stream_end`, unsure what multiple `custom_rtmp`'s - * provide on their own without URL, but it could be a privacy or payload size issue. - */ - ...this.views.customDestinations.filter(d => d.enabled).map(_ => 'custom_rtmp'), - ] - : ['custom_rtmp']; - - if (eventMetadata.platforms.includes('youtube')) { - eventMetadata.streamId = this.youtubeService.state.streamId; - eventMetadata.broadcastId = this.youtubeService.state.settings?.broadcastId; - } + if (!this.userService.isLoggedIn) { + const messages = formatStreamErrorMessage('LOGGED_OUT_ERROR'); - this.usageStatisticsService.recordEvent('stream_start', eventMetadata); - this.usageStatisticsService.recordAnalyticsEvent('StreamingStatus', { - code: info.code, - status: EStreamingState.Live, - service: streamSettings.service, - }); - this.usageStatisticsService.recordFeatureUsage('Streaming'); - } else if (info.signal === EOBSOutputSignal.Starting && shouldResolve) { - this.SET_STREAMING_STATUS(EStreamingState.Starting, time); - this.streamingStatusChange.next(EStreamingState.Starting); - } else if (info.signal === EOBSOutputSignal.Stop) { - this.SET_STREAMING_STATUS(EStreamingState.Offline, time); - this.RESET_STREAM_INFO(); - this.rejectStartStreaming(); - this.streamingStatusChange.next(EStreamingState.Offline); - this.usageStatisticsService.recordAnalyticsEvent('StreamingStatus', { - code: info.code, - status: EStreamingState.Offline, - }); - } else if (info.signal === EOBSOutputSignal.Stopping) { - this.sendStreamEndEvent(); - this.SET_STREAMING_STATUS(EStreamingState.Ending, time); - this.streamingStatusChange.next(EStreamingState.Ending); - } else if (info.signal === EOBSOutputSignal.Reconnect) { - this.SET_STREAMING_STATUS(EStreamingState.Reconnecting); - this.streamingStatusChange.next(EStreamingState.Reconnecting); - this.sendReconnectingNotification(); - } else if (info.signal === EOBSOutputSignal.ReconnectSuccess) { - this.SET_STREAMING_STATUS(EStreamingState.Live); - this.streamingStatusChange.next(EStreamingState.Live); - this.clearReconnectingNotification(); - } - } + errorText = messages.user; + diagReportMessage = messages.report; + if (messages.details) details = messages.details; - if (info.code) { - if (this.outputErrorOpen) { - console.warn('Not showing error message because existing window is open.', info); + showNativeErrorMessage = details !== ''; + } else { + if ( + !info.error || + (info.error && typeof info.error !== 'string') || + (info.error && info.error === '') + ) { + info.error = $t('An unknown %{type} error occurred.', { + type: outputType(info.type), + }); + } const messages = formatUnknownErrorMessage( info, @@ -2047,144 +2050,78 @@ export class StreamingService this.streamErrorReportMessage, ); - this.streamErrorCreated.next(messages.report); + errorText = messages.user; + diagReportMessage = messages.report; + if (messages.details) details = messages.details; - return; - } - - let errorText = this.streamErrorUserMessage; - let details = ''; - let linkToDriverInfo = false; - let showNativeErrorMessage = false; - let diagReportMessage = this.streamErrorUserMessage; - - if (info.code === EOutputCode.BadPath) { - errorText = $t( - 'Invalid Path or Connection URL. Please check your settings to confirm that they are valid.', - ); - diagReportMessage = diagReportMessage.concat(errorText); - } else if (info.code === EOutputCode.ConnectFailed) { - errorText = $t( - 'Failed to connect to the streaming server. Please check your internet connection.', - ); - diagReportMessage = diagReportMessage.concat(errorText); - } else if (info.code === EOutputCode.Disconnected) { - errorText = $t( - 'Disconnected from the streaming server. Please check your internet connection.', - ); - diagReportMessage = diagReportMessage.concat(errorText); - } else if (info.code === EOutputCode.InvalidStream) { - errorText = $t( - 'Could not access the specified channel or stream key. Please log out and back in to refresh your credentials. If the problem persists, there may be a problem connecting to the server.', - ); - diagReportMessage = diagReportMessage.concat(errorText); - } else if (info.code === EOutputCode.NoSpace) { - errorText = $t('There is not sufficient disk space to continue recording.'); - diagReportMessage = diagReportMessage.concat(errorText); - } else if (info.code === EOutputCode.Unsupported) { - errorText = - $t( - 'The output format is either unsupported or does not support more than one audio track. ', - ) + $t('Please check your settings and try again.'); - diagReportMessage = diagReportMessage.concat(errorText); - } else if (info.code === EOutputCode.OutdatedDriver) { - linkToDriverInfo = true; - errorText = $t( - 'An error occurred with the output. This is usually caused by out of date video drivers. Please ensure your Nvidia or AMD drivers are up to date and try again.', - ); - diagReportMessage = diagReportMessage.concat(errorText); - } else { - // -4 is used for generic unknown messages in OBS. Both -4 and any other code - // we don't recognize should fall into this branch and show a generic error. - - if (!this.userService.isLoggedIn) { - const messages = formatStreamErrorMessage('LOGGED_OUT_ERROR'); - - errorText = messages.user; - diagReportMessage = messages.report; - if (messages.details) details = messages.details; - - showNativeErrorMessage = details !== ''; - } else if (info.error && typeof info.error === 'string') { - const messages = formatUnknownErrorMessage( - info, - this.streamErrorUserMessage, - this.streamErrorReportMessage, - ); - - errorText = messages.user; - diagReportMessage = messages.report; - if (messages.details) details = messages.details; - - showNativeErrorMessage = details !== ''; - } + showNativeErrorMessage = details !== ''; } + } - const buttons = [$t('OK')]; + const buttons = [$t('OK')]; - const title = { - [EOBSOutputType.Streaming]: $t('Streaming Error'), - [EOBSOutputType.Recording]: $t('Recording Error'), - [EOBSOutputType.ReplayBuffer]: $t('Replay Buffer Error'), - }[info.type]; + const title = { + [EOBSOutputType.Streaming]: $t('Streaming Error'), + [EOBSOutputType.Recording]: $t('Recording Error'), + [EOBSOutputType.ReplayBuffer]: $t('Replay Buffer Error'), + }[info.type]; - if (linkToDriverInfo) buttons.push($t('Learn More')); - if (showNativeErrorMessage) { - buttons.push($t('More')); - } + if (linkToDriverInfo) buttons.push($t('Learn More')); + if (showNativeErrorMessage) { + buttons.push($t('More')); + } - this.outputErrorOpen = true; - const errorType = 'error'; - remote.dialog - .showMessageBox(Utils.getMainWindow(), { - buttons, - title, - type: errorType, - message: errorText, - }) - .then(({ response }) => { - if (linkToDriverInfo && response === 1) { - this.outputErrorOpen = false; - remote.shell.openExternal( - 'https://howto.streamlabs.com/streamlabs-obs-19/nvidia-graphics-driver-clean-install-tutorial-7000', - ); + this.outputErrorOpen = true; + const errorType = 'error'; + remote.dialog + .showMessageBox(Utils.getMainWindow(), { + buttons, + title, + type: errorType, + message: errorText, + }) + .then(({ response }) => { + if (linkToDriverInfo && response === 1) { + this.outputErrorOpen = false; + remote.shell.openExternal( + 'https://howto.streamlabs.com/streamlabs-obs-19/nvidia-graphics-driver-clean-install-tutorial-7000', + ); + } else { + let expectedResponse = 1; + if (linkToDriverInfo) { + expectedResponse = 2; + } + if (showNativeErrorMessage && response === expectedResponse) { + const buttons = [$t('OK')]; + remote.dialog + .showMessageBox({ + buttons, + title, + type: errorType, + message: details, + }) + .then(({ response }) => { + this.outputErrorOpen = false; + this.streamErrorUserMessage = ''; + this.streamErrorReportMessage = ''; + }) + .catch(() => { + this.outputErrorOpen = false; + }); } else { - let expectedResponse = 1; - if (linkToDriverInfo) { - expectedResponse = 2; - } - if (showNativeErrorMessage && response === expectedResponse) { - const buttons = [$t('OK')]; - remote.dialog - .showMessageBox({ - buttons, - title, - type: errorType, - message: details, - }) - .then(({ response }) => { - this.outputErrorOpen = false; - this.streamErrorUserMessage = ''; - this.streamErrorReportMessage = ''; - }) - .catch(() => { - this.outputErrorOpen = false; - }); - } else { - this.outputErrorOpen = false; - } + this.outputErrorOpen = false; } - }) - .catch(() => { - this.outputErrorOpen = false; - }); + } + }) + .catch(() => { + this.outputErrorOpen = false; + }); - this.windowsService.actions.closeChildWindow(); + this.windowsService.actions.closeChildWindow(); - // pass streaming error to diag report - if (info.type === EOBSOutputType.Streaming || !this.userService.isLoggedIn) { - this.streamErrorCreated.next(diagReportMessage); - } + // pass streaming error to diag report + if (info.type === EOBSOutputType.Streaming || !this.userService.isLoggedIn) { + this.streamErrorCreated.next(diagReportMessage); } } @@ -2263,7 +2200,7 @@ export class StreamingService shutdown() { Object.keys(this.contexts).forEach(display => { Object.keys(this.contexts[display]).forEach(async (contextType: keyof IOutputContext) => { - await this.destroyOutputContextIfExists(display, contextType); + this.destroyOutputContextIfExists(display, contextType); }); }); } @@ -2337,9 +2274,16 @@ export class StreamingService } @mutation() - private SET_STREAMING_STATUS(status: EStreamingState, time?: string) { + private SET_STREAMING_STATUS(status: EStreamingState, display: TDisplayType, time?: string) { + // while recording and the replay buffer are in the v2 API and streaming is in the old API + // we need to duplicate tracking the replay buffer status this.state.streamingStatus = status; - if (time) this.state.streamingStatusTime = time; + this.state.status[display].streaming = status; + + if (time) { + this.state.streamingStatusTime = time; + this.state.status[display].streamingTime = time; + } } @mutation() From 2e3934b55db50645092c7f9b824ba7061f4e6dca Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Wed, 12 Mar 2025 11:31:12 -0400 Subject: [PATCH 14/25] Fix using Advanced settings. --- app/components-react/root/StudioFooter.tsx | 8 +-- .../settings/output/output-settings.ts | 28 +++------- app/services/streaming/streaming.ts | 52 ++++++++++++++----- 3 files changed, 50 insertions(+), 38 deletions(-) diff --git a/app/components-react/root/StudioFooter.tsx b/app/components-react/root/StudioFooter.tsx index b9ba49ddfeb0..33e244cb5819 100644 --- a/app/components-react/root/StudioFooter.tsx +++ b/app/components-react/root/StudioFooter.tsx @@ -43,8 +43,10 @@ export default function StudioFooterComponent() { !RecordingModeService.views.isRecordingModeEnabled, streamQuality: PerformanceService.views.streamQuality, replayBufferOffline: StreamingService.views.replayBufferStatus === EReplayBufferState.Offline, - replayBufferStopping: StreamingService.state.replayBufferStatus === EReplayBufferState.Stopping, - replayBufferSaving: StreamingService.state.replayBufferStatus === EReplayBufferState.Saving, + replayBufferStopping: + StreamingService.state.status.horizontal.replayBuffer === EReplayBufferState.Stopping, + replayBufferSaving: + StreamingService.state.status.horizontal.replayBuffer === EReplayBufferState.Saving, recordingModeEnabled: RecordingModeService.views.isRecordingModeEnabled, replayBufferEnabled: SettingsService.views.values.Output.RecRB, })); @@ -197,7 +199,7 @@ function RecordingButton() { const { StreamingService } = Services; const { isRecording, recordingStatus } = useVuex(() => ({ isRecording: StreamingService.views.isRecording, - recordingStatus: StreamingService.state.status.horizontal.recordingTime, + recordingStatus: StreamingService.state.status.horizontal.recording, })); function toggleRecording() { diff --git a/app/services/settings/output/output-settings.ts b/app/services/settings/output/output-settings.ts index 1dee9b443eb2..716c5d0af900 100644 --- a/app/services/settings/output/output-settings.ts +++ b/app/services/settings/output/output-settings.ts @@ -246,27 +246,14 @@ export class OutputSettingsService extends Service { 'Mode', ); - const oldQualityName = this.settingsService.findSettingValue(output, 'Recording', 'RecQuality'); - let quality: ERecordingQuality = ERecordingQuality.HigherQuality; - switch (oldQualityName) { - case 'Small': - quality = ERecordingQuality.HighQuality; - break; - case 'HQ': - quality = ERecordingQuality.HigherQuality; - break; - case 'Lossless': - quality = ERecordingQuality.Lossless; - break; - case 'Stream': - quality = ERecordingQuality.Stream; - break; - } + const convertedEncoderName: + | EObsSimpleEncoder.x264_lowcpu + | EObsAdvancedEncoder = this.convertEncoderToNewAPI(this.getSettings().recording.encoder); - const videoEncoder = obsEncoderToEncoderFamily( - this.settingsService.findSettingValue(output, 'Streaming', 'Encoder') || - this.settingsService.findSettingValue(output, 'Streaming', 'StreamEncoder'), - ) as EEncoderFamily; + const videoEncoder: EObsAdvancedEncoder = + convertedEncoderName === EObsSimpleEncoder.x264_lowcpu + ? EObsAdvancedEncoder.obs_x264 + : convertedEncoderName; const enforceBitrateKey = mode === 'Advanced' ? 'ApplyServiceSettings' : 'EnforceBitrate'; const enforceServiceBitrate = this.settingsService.findSettingValue( @@ -282,7 +269,6 @@ export class OutputSettingsService extends Service { ); const useAdvanced = this.settingsService.findSettingValue(output, 'Streaming', 'UseAdvanced'); - console.log('useAdvanced', useAdvanced); const customEncSettings = this.settingsService.findSettingValue( output, diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index 66ba1794b6ac..62a7fd510c14 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -1010,7 +1010,7 @@ export class StreamingService NodeObs.OBS_service_stopStreaming(true, 'vertical'); // Refactor when move streaming to new API if (this.state.status.vertical.streaming !== EStreamingState.Offline) { - this.SET_VERTICAL_STREAMING_STATUS(EStreamingState.Offline); + this.SET_STREAMING_STATUS(EStreamingState.Offline, 'vertical'); } } @@ -1020,7 +1020,7 @@ export class StreamingService // Refactor when move streaming to new API const time = new Date().toISOString(); if (this.state.status.vertical.streaming === EStreamingState.Offline) { - this.SET_VERTICAL_STREAMING_STATUS(EStreamingState.Live, time); + this.SET_STREAMING_STATUS(EStreamingState.Live, 'vertical', time); } signalChanged.unsubscribe(); @@ -1134,7 +1134,7 @@ export class StreamingService NodeObs.OBS_service_stopStreaming(false, 'vertical'); // Refactor when move streaming to new API if (this.state.status.vertical.streaming !== EStreamingState.Offline) { - this.SET_VERTICAL_STREAMING_STATUS(EStreamingState.Offline); + this.SET_STREAMING_STATUS(EStreamingState.Offline, 'vertical'); } signalChanged.unsubscribe(); } @@ -1181,7 +1181,7 @@ export class StreamingService NodeObs.OBS_service_stopStreaming(true, 'horizontal'); // Refactor when move streaming to new API if (this.state.status.vertical.streaming !== EStreamingState.Offline) { - this.SET_VERTICAL_STREAMING_STATUS(EStreamingState.Offline); + this.SET_STREAMING_STATUS(EStreamingState.Offline, 'vertical'); } NodeObs.OBS_service_stopStreaming(true, 'vertical'); } else { @@ -1566,14 +1566,44 @@ export class StreamingService code: info.code, }); - // In the case that the user is streaming but not recording, a recording instance - // was created for the replay buffer. If the replay buffer is stopped, the recording - // instance should be destroyed. - if (this.state.status.horizontal.recording === ERecordingState.Offline) { + // There are a few cases where a recording and streaming instance are created for the replay buffer. + // In these cases, the created recording and streaming instances should be destroyed + // when the replay buffer is stopped. + // 1. Simple Replay Buffer: When using the replay buffer without recording or streaming, + // a simple recording instance is created for the replay buffer. + // 2. Simple Replay Buffer: When using the replay butter while streaming but not recording, + // a simple recording instance is created for the replay buffer. + // 3. Advanced Replay Buffer: When using the replay buffer without recording or streaming, + // an advanced recording instance is created for the replay buffer. This advanced recording + // instance will create an advanced streaming instance if it does not exist. + // 4. Advanced Replay Buffer: When using the replay buffer while streaming but not recording, + // a recording instance is created for the replay buffer. If the replay buffer is stopped, + // the recording instance should be destroyed. + + // destroy any recording instances created for use with the replay buffer + if (this.state.status[display].recording === ERecordingState.Offline) { this.destroyOutputContextIfExists(display, 'recording'); + console.log('destroyed recording instance for replay buffer'); + } + + // destroy any streaming instances created by the recording instance for use with the replay buffer + // Note: this is only the case when recording without streaming in advanced mode + if (this.state.status[display].streaming === EStreamingState.Offline) { + this.destroyOutputContextIfExists(display, 'streaming'); + console.log('destroyed streaming instance for replay buffer'); } this.destroyOutputContextIfExists(display, 'replayBuffer'); + + // THE BELOW WORKS + // // In the case that the user is streaming but not recording, a recording instance + // // was created for the replay buffer. If the replay buffer is stopped, the recording + // // instance should be destroyed. + // if (this.state.status.horizontal.recording === ERecordingState.Offline) { + // this.destroyOutputContextIfExists(display, 'recording'); + // } + + // this.destroyOutputContextIfExists(display, 'replayBuffer'); } } @@ -2286,12 +2316,6 @@ export class StreamingService } } - @mutation() - private SET_VERTICAL_STREAMING_STATUS(status: EStreamingState, time?: string) { - this.state.streamingStatus = status; - if (time) this.state.streamingStatusTime = time; - } - @mutation() private SET_RECORDING_STATUS(status: ERecordingState, display: TDisplayType, time: string) { // while recording and the replay buffer are in the v2 API and streaming is in the old API From d06ce47fc85f7ea64ba247d842f443d569f7c1e1 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Wed, 12 Mar 2025 12:40:30 -0400 Subject: [PATCH 15/25] Only destroy replay buffer on app close. --- app/services/streaming/streaming.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index 62a7fd510c14..92c25ae4d7d2 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -1593,7 +1593,7 @@ export class StreamingService console.log('destroyed streaming instance for replay buffer'); } - this.destroyOutputContextIfExists(display, 'replayBuffer'); + // this.destroyOutputContextIfExists(display, 'replayBuffer'); // THE BELOW WORKS // // In the case that the user is streaming but not recording, a recording instance From 9932d37a1552859a24d180545fb1dcb05f30218e Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Mon, 24 Mar 2025 12:27:42 -0400 Subject: [PATCH 16/25] Added Advanced Recording test. --- .../pages/RecordingHistory.tsx | 6 +- .../root/NotificationsArea.tsx | 2 +- test/helpers/modules/settings/settings.ts | 31 +++---- test/regular/recording.ts | 86 +++++++++++++++---- 4 files changed, 93 insertions(+), 32 deletions(-) diff --git a/app/components-react/pages/RecordingHistory.tsx b/app/components-react/pages/RecordingHistory.tsx index e7a34e3812e3..f018e0c1998e 100644 --- a/app/components-react/pages/RecordingHistory.tsx +++ b/app/components-react/pages/RecordingHistory.tsx @@ -293,7 +293,11 @@ export function RecordingHistory() { <div className={styles.recording} key={recording.timestamp}> <span style={{ marginRight: '8px' }}>{formattedTimestamp(recording.timestamp)}</span> <Tooltip title={$t('Show in folder')}> - <span onClick={() => showFile(recording.filename)} className={styles.filename}> + <span + data-test="filename" + onClick={() => showFile(recording.filename)} + className={styles.filename} + > {recording.filename} </span> </Tooltip> diff --git a/app/components-react/root/NotificationsArea.tsx b/app/components-react/root/NotificationsArea.tsx index 4307621658fe..5d27c49d630f 100644 --- a/app/components-react/root/NotificationsArea.tsx +++ b/app/components-react/root/NotificationsArea.tsx @@ -48,7 +48,7 @@ class NotificationsModule { message .info({ - content: <MessageNode notif={notif} />, + content: <MessageNode notif={notif} data-testid="notification" />, duration: notif.lifeTime === -1 ? 0 : notif.lifeTime / 1000, key: `${notif.message}${notif.date}`, onClick: () => this.clickNotif(), diff --git a/test/helpers/modules/settings/settings.ts b/test/helpers/modules/settings/settings.ts index 98f98d627edc..da59998ca731 100644 --- a/test/helpers/modules/settings/settings.ts +++ b/test/helpers/modules/settings/settings.ts @@ -1,17 +1,9 @@ -import { - click, - clickButton, - clickCheckbox, - focusChild, - useChildWindow, - useMainWindow, -} from '../core'; +import { click, clickButton, focusChild, useChildWindow, useMainWindow } from '../core'; import { mkdtemp } from 'fs-extra'; import { tmpdir } from 'os'; import * as path from 'path'; import { setInputValue } from '../forms/base'; -import { FormMonkey } from '../../form-monkey'; -import { TExecutionContext } from '../../../helpers/webdriver'; +import { setFormDropdown } from '../../webdriver/forms'; /** * Open the settings window with a given category selected @@ -35,12 +27,21 @@ export async function showSettingsWindow(category: string, cb?: () => Promise<un /** * Set recording path to a temp dir */ -export async function setTemporaryRecordingPath(): Promise<string> { +export async function setTemporaryRecordingPath(advanced: boolean = false): Promise<string> { const tmpDir = await mkdtemp(path.join(tmpdir(), 'slobs-recording-')); - await showSettingsWindow('Output', async () => { - await setInputValue('[data-name="FilePath"] input', tmpDir); - await clickButton('Done'); - }); + + if (advanced) { + await showSettingsWindow('Output', async () => { + await setFormDropdown('Output Mode', 'Advanced'); + await clickButton('Recording'); + await setInputValue('[data-name="RecFilePath"] input', tmpDir); + }); + } else { + await showSettingsWindow('Output', async () => { + await setInputValue('[data-name="FilePath"] input', tmpDir); + await clickButton('Done'); + }); + } return tmpDir; } diff --git a/test/regular/recording.ts b/test/regular/recording.ts index 551db9d4d377..2a88d5d71070 100644 --- a/test/regular/recording.ts +++ b/test/regular/recording.ts @@ -1,5 +1,5 @@ import { readdir } from 'fs-extra'; -import { test, useWebdriver } from '../helpers/webdriver'; +import { test, TExecutionContext, useWebdriver } from '../helpers/webdriver'; import { sleep } from '../helpers/sleep'; import { startRecording, stopRecording } from '../helpers/modules/streaming'; import { FormMonkey } from '../helpers/form-monkey'; @@ -8,29 +8,42 @@ import { setTemporaryRecordingPath, showSettingsWindow, } from '../helpers/modules/settings/settings'; -import { clickButton, focusMain } from '../helpers/modules/core'; +import { + clickButton, + clickWhenDisplayed, + focusMain, + getNumElements, + waitForDisplayed, +} from '../helpers/modules/core'; import { logIn } from '../helpers/webdriver/user'; import { toggleDualOutputMode } from '../helpers/modules/dual-output'; +import { setFormDropdown } from '../helpers/webdriver/forms'; +import { setInputValue } from '../helpers/modules/forms'; +// not a react hook +// eslint-disable-next-line react-hooks/rules-of-hooks useWebdriver(); /** - * Recording with one context active (horizontal) + * Iterate over all formats and record a 0.5s video in each. + * @param t - AVA test context + * @param advanced - whether to use advanced settings + * @returns number of formats */ - -test('Recording', async t => { - const tmpDir = await setTemporaryRecordingPath(); - - // low resolution reduces CPU usage - await setOutputResolution('100x100'); - +async function createRecordingFiles(advanced: boolean = false): Promise<number> { const formats = ['flv', 'mp4', 'mov', 'mkv', 'ts', 'm3u8']; // Record 0.5s video in every format for (const format of formats) { await showSettingsWindow('Output', async () => { - const form = new FormMonkey(t); - await form.setInputValue(await form.getInputSelectorByTitle('Recording Format'), format); + await sleep(2000); + if (advanced) { + await clickButton('Recording'); + } + + await sleep(2000); + await setFormDropdown('Recording Format', format); + await sleep(2000); await clickButton('Done'); }); @@ -39,15 +52,52 @@ test('Recording', async t => { await sleep(500); await stopRecording(); - // Wait to ensure that output setting are editable - await sleep(500); + // Confirm notification has been shown + await focusMain(); + await clickWhenDisplayed('span=A new Recording has been completed. Click for more info'); + await waitForDisplayed('h1=Recordings', { timeout: 1000 }); } + return Promise.resolve(formats.length); +} + +async function validateRecordingFiles(t: TExecutionContext, tmpDir: string, numFormats: number) { // Check that every file was created const files = await readdir(tmpDir); + const numFiles = files.length; + // M3U8 creates multiple TS files in addition to the catalog itself. - t.true(files.length >= formats.length, `Files that were created:\n${files.join('\n')}`); + t.true(numFiles >= numFormats, `Files that were created:\n${files.join('\n')}`); + waitForDisplayed('h1=Recordings'); + + const numRecordings = await getNumElements('[data-test=filename]'); + t.is(numRecordings, numFiles, 'Number of recordings in history matches number of files recorded'); +} + +/** + * Recording with one context active (horizontal) + */ + +test('Recording', async t => { + // low resolution reduces CPU usage + await setOutputResolution('100x100'); + const tmpDir = await setTemporaryRecordingPath(); + + // Simple Recording + const numFormats = await createRecordingFiles(); + await validateRecordingFiles(t, tmpDir, numFormats); +}); + +test('Advanced Recording', async t => { + // low resolution reduces CPU usage + await setOutputResolution('100x100'); + + // Advanced Recording + const tmpAdvDir = await setTemporaryRecordingPath(true); + const numFiles = await createRecordingFiles(true); + console.log('numFiles', numFiles); + await validateRecordingFiles(t, tmpAdvDir, numFiles); }); /** @@ -60,6 +110,12 @@ test('Recording with two contexts active', async t => { const tmpDir = await setTemporaryRecordingPath(); // low resolution reduces CPU usage await setOutputResolution('100x100'); + await showSettingsWindow('Output', async () => { + await setFormDropdown('Output Mode', 'Advanced'); + await clickButton('Recording'); + await setInputValue('[data-name="RecFilePath"] input', tmpDir); + }); + const formats = ['flv', 'mp4', 'mov', 'mkv', 'ts', 'm3u8']; // Record 0.5s video in every format for (const format of formats) { From 4d4d247e5a58491c33176414e3a196f8a54c996f Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Mon, 24 Mar 2025 13:38:08 -0400 Subject: [PATCH 17/25] Fix Advanced Recording. --- app/components-react/root/StudioFooter.tsx | 7 +- .../api/external-api/streaming/streaming.ts | 2 +- app/services/highlighter/index.ts | 2 + .../settings/output/output-settings.ts | 23 +- app/services/settings/settings.ts | 5 +- app/services/streaming/streaming.ts | 514 +++++++++++++----- test/regular/recording.ts | 3 - 7 files changed, 396 insertions(+), 160 deletions(-) diff --git a/app/components-react/root/StudioFooter.tsx b/app/components-react/root/StudioFooter.tsx index 33e244cb5819..8619c748836c 100644 --- a/app/components-react/root/StudioFooter.tsx +++ b/app/components-react/root/StudioFooter.tsx @@ -4,7 +4,7 @@ import { EStreamQuality } from '../../services/performance'; import { EStreamingState, EReplayBufferState, ERecordingState } from '../../services/streaming'; import { Services } from '../service-provider'; import { $t } from '../../services/i18n'; -import { useVuex } from '../hooks'; +import { useDebounce, useVuex } from '../hooks'; import styles from './StudioFooter.m.less'; import PerformanceMetrics from '../shared/PerformanceMetrics'; import TestWidgets from './TestWidgets'; @@ -42,7 +42,8 @@ export default function StudioFooterComponent() { StreamingService.views.supports('stream-schedule') && !RecordingModeService.views.isRecordingModeEnabled, streamQuality: PerformanceService.views.streamQuality, - replayBufferOffline: StreamingService.views.replayBufferStatus === EReplayBufferState.Offline, + replayBufferOffline: + StreamingService.state.status.horizontal.replayBuffer === EReplayBufferState.Offline, replayBufferStopping: StreamingService.state.status.horizontal.replayBuffer === EReplayBufferState.Stopping, replayBufferSaving: @@ -216,7 +217,7 @@ function RecordingButton() { > <button className={cx(styles.recordButton, 'record-button', { active: isRecording })} - onClick={toggleRecording} + onClick={useDebounce(200, toggleRecording)} > <span> {recordingStatus === ERecordingState.Stopping ? ( diff --git a/app/services/api/external-api/streaming/streaming.ts b/app/services/api/external-api/streaming/streaming.ts index 42d2c33ee163..9982daf46bad 100644 --- a/app/services/api/external-api/streaming/streaming.ts +++ b/app/services/api/external-api/streaming/streaming.ts @@ -121,7 +121,7 @@ export class StreamingService implements ISerializable { /** * Toggles recording. */ - toggleRecording(): void { + toggleRecording(): Promise<void> { return this.streamingService.toggleRecording(); } diff --git a/app/services/highlighter/index.ts b/app/services/highlighter/index.ts index 65835f3d3cca..e49ba6b8f21d 100644 --- a/app/services/highlighter/index.ts +++ b/app/services/highlighter/index.ts @@ -354,6 +354,8 @@ export class HighlighterService extends PersistentStatefulService<IHighlighterSt this.handleStreamingChanges(); + if (!this.userService.isLoggedIn) return; + this.streamingService.replayBufferFileWrite.subscribe(async clipPath => { const message = $t('A new Highlight has been saved. Click to edit in the Highlighter'); diff --git a/app/services/settings/output/output-settings.ts b/app/services/settings/output/output-settings.ts index 716c5d0af900..7002fd093b95 100644 --- a/app/services/settings/output/output-settings.ts +++ b/app/services/settings/output/output-settings.ts @@ -5,8 +5,17 @@ import { HighlighterService } from 'services/highlighter'; import { Inject } from 'services/core/injector'; import { Dictionary } from 'vuex'; import { AudioService } from 'app-services'; -import { ERecordingQuality, ERecordingFormat } from 'obs-studio-node'; - +import { + ERecordingQuality, + ERecordingFormat, + ISimpleStreaming, + IAdvancedStreaming, +} from 'obs-studio-node'; + +export type IStreamingOutputSettings = Omit< + Partial<ISimpleStreaming | IAdvancedStreaming>, + 'videoEncoder' +>; /** * list of encoders for simple mode */ @@ -467,19 +476,15 @@ export class OutputSettingsService extends Service { const prefix: string = this.settingsService.findSettingValue( output, - 'Recording', + 'Replay Buffer', 'RecRBPrefix', ); const suffix: string = this.settingsService.findSettingValue( output, - 'Recording', + 'Replay Buffer', 'RecRBSuffix', ); - const duration: number = this.settingsService.findSettingValue( - output, - 'Stream Delay', - 'DelaySec', - ); + const duration: number = this.settingsService.findSettingValue(output, 'Output', 'RecRBTime'); const useStreamEncoders = this.settingsService.findSettingValue(output, 'Recording', 'RecEncoder') === 'none'; diff --git a/app/services/settings/settings.ts b/app/services/settings/settings.ts index 6958e4d6ac3e..4efead0ed7b0 100644 --- a/app/services/settings/settings.ts +++ b/app/services/settings/settings.ts @@ -150,7 +150,7 @@ class SettingsViews extends ViewHandler<ISettingsServiceState> { const settingsValues: Partial<ISettingsValues> = {}; for (const groupName in this.state) { - this.state[groupName].formData.forEach(subGroup => { + this.state[groupName].formData.forEach((subGroup: ISettingsSubCategory) => { subGroup.parameters.forEach(parameter => { (settingsValues as any)[groupName] = settingsValues[groupName as keyof ISettingsValues] || {}; @@ -264,6 +264,7 @@ export class SettingsService extends StatefulService<ISettingsServiceState> { private videoEncodingOptimizationService: VideoEncodingOptimizationService; audioRefreshed = new Subject(); + // settingsUpdated = new Subject<DeepPartial<ISettingsValues>>(); get views() { return new SettingsViews(this.state); @@ -623,6 +624,8 @@ export class SettingsService extends StatefulService<ISettingsServiceState> { this.setSettings(categoryName, formSubCategories); }); + + // this.settingsUpdated.next(patch); } private setAudioSettings(settingsData: ISettingsSubCategory[]) { diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index aa50c1608ddc..67be07a2ea88 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -131,7 +131,18 @@ export interface IOBSOutputSignalInfo { service: string; // 'default' | 'vertical' } +type TOBSOutputType = 'streaming' | 'recording' | 'replayBuffer'; + interface IOutputContext { + // simpleStreaming: ISimpleStreaming; + // simpleReplayBuffer: ISimpleReplayBuffer; + // simpleRecording: ISimpleRecording; + // advancedStreaming: IAdvancedStreaming; + // advancedRecording: IAdvancedRecording; + // advancedReplayBuffer: IAdvancedReplayBuffer; + // streaming: ISimpleStreaming | IAdvancedStreaming; + // recording: ISimpleRecording | IAdvancedRecording; + // replayBuffer: ISimpleReplayBuffer | IAdvancedReplayBuffer; streaming: ISimpleStreaming | IAdvancedStreaming; recording: ISimpleRecording | IAdvancedRecording; replayBuffer: ISimpleReplayBuffer | IAdvancedReplayBuffer; @@ -271,6 +282,10 @@ export class StreamingService deep: true, }, ); + + // this.settingsService.settingsUpdated.subscribe(() => { + // this.updateOutputInstance(); + // }); } get views() { @@ -1055,7 +1070,8 @@ export class StreamingService if ( recordWhenStreaming && - this.state.status.horizontal.recording === ERecordingState.Offline + this.state.status.horizontal.recording === ERecordingState.Offline && + this.state.status.vertical.recording === ERecordingState.Offline ) { this.toggleRecording(); } @@ -1065,7 +1081,8 @@ export class StreamingService if ( replayWhenStreaming && - this.state.status.horizontal.replayBuffer === EReplayBufferState.Offline + this.state.status.horizontal.replayBuffer === EReplayBufferState.Offline && + this.state.status.vertical.replayBuffer === EReplayBufferState.Offline ) { this.startReplayBuffer(); } @@ -1218,7 +1235,7 @@ export class StreamingService this.toggleRecording(); } - toggleRecording() { + async toggleRecording() { // stop recording if ( this.state.status.horizontal.recording === ERecordingState.Recording && @@ -1277,17 +1294,20 @@ export class StreamingService if (this.state.streamingStatus !== EStreamingState.Offline) { // In dual output mode, if the streaming status is starting then this call to toggle recording came from the function to toggle streaming. // In this case, only stream the horizontal display (don't record the horizontal display) and record the vertical display. - this.createRecording('vertical', 2); + await this.createRecording('vertical', 2, true); } else { // Otherwise, record both displays in dual output mode - this.createRecording('vertical', 2); - this.createRecording('horizontal', 1); + await this.createRecording('vertical', 2, true); + await this.createRecording('horizontal', 1, true); } } else { // In single output mode, recording only the horizontal display - this.createRecording('horizontal', 1); + await this.createRecording('horizontal', 1, true); } } + this.logContexts('horizontal', '*** toggleRecording'); + this.logContexts('vertical', '+++ toggleRecording'); + console.log('\n\n'); } /** @@ -1296,68 +1316,81 @@ export class StreamingService * @param index - The index of the audio track * @param skipStart - Whether to skip starting the recording. This is used when creating a recording instance for the replay buffer */ - private createRecording(display: TDisplayType, index: number, skipStart: boolean = false) { - this.destroyOutputContextIfExists(display, 'recording'); - + private async createRecording(display: TDisplayType, index: number, start: boolean = false) { const mode = this.outputSettingsService.getSettings().mode; - - const recording = - mode === 'Advanced' - ? (AdvancedRecordingFactory.create() as IAdvancedRecording) - : (SimpleRecordingFactory.create() as ISimpleRecording); - const settings = this.outputSettingsService.getRecordingSettings(); - // assign settings - Object.keys(settings).forEach(key => { - if ((settings as any)[key] === undefined) return; - - // share the video encoder with the streaming instance if it exists - if (key === 'videoEncoder') { - recording.videoEncoder = - this.contexts[display].streaming?.videoEncoder ?? - VideoEncoderFactory.create(settings.videoEncoder, 'video-encoder'); - } else { - (recording as any)[key] = (settings as any)[key]; - } - }); + // recordings must have a streaming instance + this.validateOrCreateOutputInstance(mode, display, 'streaming'); // handle unique properties (including audio) if (mode === 'Advanced') { + const recording = AdvancedRecordingFactory.create() as IAdvancedRecording; + const stream = this.contexts[display].streaming as IAdvancedStreaming; + + Object.keys(settings).forEach(key => { + if ((settings as any)[key] === undefined) return; + + // share the video encoder with the streaming instance if it exists + if (key === 'videoEncoder') { + recording.videoEncoder = + stream?.videoEncoder ?? + VideoEncoderFactory.create(settings.videoEncoder, 'video-encoder'); + } else { + (recording as any)[key] = (settings as any)[key]; + } + }); + // output resolutions const resolution = this.videoSettingsService.outputResolutions[display]; - (recording as IAdvancedRecording).outputWidth = resolution.outputWidth; - (recording as IAdvancedRecording).outputHeight = resolution.outputHeight; + recording.outputWidth = resolution.outputWidth; + recording.outputHeight = resolution.outputHeight; // audio track this.createAudioTrack(index); - // streaming instance - this.validateOrCreateOutputInstance(mode, display, 'streaming'); - (recording as IAdvancedRecording).streaming = this.contexts[display] - .streaming as IAdvancedStreaming; + recording.streaming = stream; + this.contexts[display].recording = recording as IAdvancedRecording; } else { - (recording as ISimpleRecording).audioEncoder = AudioEncoderFactory.create(); - } + const recording = SimpleRecordingFactory.create() as ISimpleRecording; + const stream = this.contexts[display].streaming as ISimpleStreaming; + + Object.keys(settings).forEach(key => { + if ((settings as any)[key] === undefined) return; - // save in state - this.contexts[display].recording = recording; + // share the video encoder with the streaming instance if it exists + if (key === 'videoEncoder') { + recording.videoEncoder = + stream?.videoEncoder ?? + VideoEncoderFactory.create(settings.videoEncoder, 'video-encoder'); + } else { + (recording as any)[key] = (settings as any)[key]; + } + }); + + recording.streaming = stream; + recording.audioEncoder = AudioEncoderFactory.create(); + this.contexts[display].recording = recording as ISimpleRecording; + } // assign context this.contexts[display].recording.video = this.videoSettingsService.contexts[display]; // set signal handler - this.contexts[display].recording.signalHandler = async signal => { + this.contexts[display].recording.signalHandler = async (signal: EOutputSignal) => { console.log('recording signal', signal); await this.handleSignal(signal, display); }; // The replay buffer requires a recording instance. If the user is streaming but not recording, // a recording instance still needs to be created but does not need to be started. - if (skipStart) return; + if (start) { + // start recording + this.contexts[display].recording.start(); + } - // start recording - this.contexts[display].recording.start(); + this.logContexts(display, 'createRecording created '); + return Promise.resolve(this.contexts[display].recording); } /** @@ -1366,22 +1399,18 @@ export class StreamingService * @param index - The index of the audio track * @param skipStart - Whether to skip starting the streaming. This is used when creating a streaming instance for advanced recording */ - private createStreaming(display: TDisplayType, index: number, skipStart: boolean = false) { - this.destroyOutputContextIfExists(display, 'streaming'); - + private async createStreaming(display: TDisplayType, index: number, start: boolean = false) { const mode = this.outputSettingsService.getSettings().mode; const settings = this.outputSettingsService.getStreamingSettings(); - console.log('createStreaming settings', settings); - const stream = mode === 'Advanced' ? (AdvancedStreamingFactory.create() as IAdvancedStreaming) : (SimpleStreamingFactory.create() as ISimpleStreaming); // assign settings - Object.keys(settings).forEach(key => { + Object.keys(settings).forEach((key: keyof Partial<ISimpleStreaming>) => { if ((settings as any)[key] === undefined) return; // share the video encoder with the recording instance if it exists @@ -1398,11 +1427,9 @@ export class StreamingService const resolution = this.videoSettingsService.outputResolutions[display]; stream.outputWidth = resolution.outputWidth; stream.outputHeight = resolution.outputHeight; - // stream audio track this.createAudioTrack(index); stream.audioTrack = index; - // Twitch VOD audio track if (stream.enableTwitchVOD && stream.twitchTrack) { this.createAudioTrack(stream.twitchTrack); @@ -1411,29 +1438,39 @@ export class StreamingService stream.twitchTrack = index + 1; this.createAudioTrack(stream.twitchTrack); } + + this.contexts[display].streaming = stream as IAdvancedStreaming; + } else if (this.isSimpleStreaming(stream)) { + stream.audioEncoder = AudioEncoderFactory.create(); + this.contexts[display].streaming = stream as ISimpleStreaming; } else { - stream.audioEncoder = - (this.contexts[display].recording as ISimpleRecording)?.audioEncoder ?? - AudioEncoderFactory.create(); + throwStreamError( + 'UNKNOWN_STREAMING_ERROR_WITH_MESSAGE', + {}, + 'Unable to create streaming instance', + ); } - stream.video = this.videoSettingsService.contexts[display]; - stream.signalHandler = async signal => { + this.contexts[display].streaming.video = this.videoSettingsService.contexts[display]; + this.contexts[display].streaming.signalHandler = async signal => { console.log('streaming signal', signal); await this.handleSignal(signal, display); }; - this.contexts[display].streaming = stream; - - if (skipStart) return; - - // TODO: fully implement streaming this.contexts[display].streaming.service = ServiceFactory.legacySettings; this.contexts[display].streaming.delay = DelayFactory.create(); this.contexts[display].streaming.reconnect = ReconnectFactory.create(); this.contexts[display].streaming.network = NetworkFactory.create(); - this.contexts[display].streaming.start(); + if (start) { + this.contexts[display].streaming.start(); + } + + console.log( + 'createdStreaming this.contexts[display].streaming', + this.contexts[display].streaming, + ); + return Promise.resolve(this.contexts[display].streaming); } /** @@ -1531,13 +1568,12 @@ export class StreamingService } await this.markersService.exportCsv(parsedName); - // destroy recording instance + // Finally, all actions are completed and the recording context can be destroyed this.destroyOutputContextIfExists(display, 'recording'); + // Also destroy the streaming instance if it was only created for recording // Note: this is only the case when recording without streaming in advanced mode - if (this.state.status[display].streaming === EStreamingState.Offline) { - this.destroyOutputContextIfExists(display, 'streaming'); - } + this.destroyOutputContextIfExists(display, 'streaming', true); this.latestRecordingPath.next(fileName); } @@ -1558,7 +1594,10 @@ export class StreamingService } as Dictionary<EReplayBufferState>)[info.signal]; // We received a signal we didn't recognize - if (!nextState) return; + if (!nextState) { + this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Offline, display); + return; + } const time = new Date().toISOString(); this.SET_REPLAY_BUFFER_STATUS(nextState, display, time); @@ -1593,61 +1632,49 @@ export class StreamingService // a recording instance is created for the replay buffer. If the replay buffer is stopped, // the recording instance should be destroyed. - // destroy any recording instances created for use with the replay buffer - if (this.state.status[display].recording === ERecordingState.Offline) { - this.destroyOutputContextIfExists(display, 'recording'); - console.log('destroyed recording instance for replay buffer'); - } - - // destroy any streaming instances created by the recording instance for use with the replay buffer - // Note: this is only the case when recording without streaming in advanced mode - if (this.state.status[display].streaming === EStreamingState.Offline) { - this.destroyOutputContextIfExists(display, 'streaming'); - console.log('destroyed streaming instance for replay buffer'); - } - - this.destroyOutputContextIfExists(display, 'replayBuffer'); - // THE BELOW WORKS - // // In the case that the user is streaming but not recording, a recording instance - // // was created for the replay buffer. If the replay buffer is stopped, the recording - // // instance should be destroyed. - // if (this.state.status.horizontal.recording === ERecordingState.Offline) { - // this.destroyOutputContextIfExists(display, 'recording'); - // } + // In the case that the user is streaming but not recording, a recording instance + // was created for the replay buffer. If the replay buffer is stopped, the recording + // instance should be destroyed. - // this.destroyOutputContextIfExists(display, 'replayBuffer'); + // if (offline) { + // this.destroyOutputContextIfExists(display, 'streaming'); + // } + // this.contexts[display].replayBuffer.recording = null; + // this.contexts[display].replayBuffer.streaming = null; + this.destroyOutputContextIfExists(display, 'replayBuffer'); + this.destroyOutputContextIfExists(display, 'recording'); + this.destroyOutputContextIfExists(display, 'streaming'); } } splitFile(display: TDisplayType = 'horizontal') { if ( - this.state.status.horizontal.recording === ERecordingState.Recording && + this.state.status[display].recording === ERecordingState.Recording && this.contexts[display].recording ) { this.contexts[display].recording.splitFile(); } } - // TODO migrate streaming to new API - startReplayBuffer(display: TDisplayType = 'horizontal') { if (this.state.status[display].replayBuffer !== EReplayBufferState.Offline) return; // change the replay buffer status for the loading animation - // this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Running, display); + this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Running, display); this.createReplayBuffer(display); } createReplayBuffer(display: TDisplayType = 'horizontal') { - this.destroyOutputContextIfExists(display, 'replayBuffer'); + // this.destroyOutputContextIfExists(display, 'replayBuffer'); const mode = this.outputSettingsService.getSettings().mode; - // A replay buffer requires a recording instance + // A replay buffer requires a recording instance and a streaming instance this.validateOrCreateOutputInstance(mode, display, 'recording'); + this.validateOrCreateOutputInstance(mode, display, 'streaming'); - const settings = this.outputSettingsService.getRecordingSettings(); + const settings = this.outputSettingsService.getReplayBufferSettings(); const replayBuffer = mode === 'Advanced' ? (AdvancedReplayBufferFactory.create() as IAdvancedReplayBuffer) @@ -1659,21 +1686,39 @@ export class StreamingService (replayBuffer as any)[key] = (settings as any)[key]; }); - replayBuffer.recording = - mode === 'Advanced' - ? (this.contexts[display].recording as IAdvancedRecording) - : (this.contexts[display].recording as ISimpleRecording); - - this.contexts[display].replayBuffer = - mode === 'Advanced' - ? (replayBuffer as IAdvancedReplayBuffer) - : (replayBuffer as ISimpleReplayBuffer); - - this.contexts[display].replayBuffer.video = this.videoSettingsService.contexts[display]; - this.contexts[display].replayBuffer.signalHandler = async signal => { - console.log('replay buffer signal', signal); - await this.handleSignal(signal, display); - }; + // replayBuffer.recording = + // mode === 'Advanced' + // ? (this.contexts[display].recording as IAdvancedRecording) + // : (this.contexts[display].recording as ISimpleRecording); + + // // replayBuffer.recording.signalHandler = async signal => { + // // console.log('replay buffer recording signal', signal); + // // await this.handleSignal(signal, display); + // // }; + + // replayBuffer.streaming = + // mode === 'Advanced' + // ? (this.contexts[display].streaming as IAdvancedStreaming) + // : (this.contexts[display].streaming as ISimpleStreaming); + + // // this.contexts[display].replayBuffer = + // // mode === 'Advanced' + // // ? (replayBuffer as IAdvancedReplayBuffer) + // // : (replayBuffer as ISimpleReplayBuffer); + + // if (this.isAdvancedReplayBuffer(replayBuffer)) { + // (this.contexts[display] + // .replayBuffer as IAdvancedReplayBuffer) = replayBuffer as IAdvancedReplayBuffer; + // } else { + // (this.contexts[display] + // .replayBuffer as ISimpleReplayBuffer) = replayBuffer as ISimpleReplayBuffer; + // } + + // this.contexts[display].replayBuffer.video = this.videoSettingsService.contexts[display]; + // this.contexts[display].replayBuffer.signalHandler = async signal => { + // console.log('replay buffer signal', signal); + // await this.handleSignal(signal, display); + // }; this.contexts[display].replayBuffer.start(); this.usageStatisticsService.recordFeatureUsage('ReplayBuffer'); @@ -1701,7 +1746,72 @@ export class StreamingService this.contexts[display].replayBuffer.save(); } - private validateOrCreateOutputInstance( + private updateOutputInstance() { + if ( + this.contexts.horizontal.streaming && + this.state.status.horizontal.streaming !== EStreamingState.Offline + ) { + const settings = this.outputSettingsService.getStreamingSettings() as Omit< + Partial<ISimpleStreaming | IAdvancedStreaming>, + 'videoEncoder' + >; + const outputInstance: ISimpleStreaming | IAdvancedStreaming = this.contexts.horizontal + .streaming; + + Object.keys(settings).forEach( + (key: keyof Omit<Partial<ISimpleStreaming | IAdvancedStreaming>, 'videoEncoder'>) => { + if ((settings as any)[key] === undefined) return; + if (outputInstance[key] !== settings[key]) { + (this.contexts.horizontal.streaming as any)[key] = (settings as any)[key]; + } + }, + ); + } + + if ( + this.contexts.horizontal.recording && + this.state.status.horizontal.recording !== ERecordingState.Offline + ) { + const settings = this.outputSettingsService.getRecordingSettings() as Omit< + Partial<ISimpleRecording | IAdvancedRecording>, + 'videoEncoder' + >; + + const outputInstance: ISimpleRecording | IAdvancedRecording = this.contexts.horizontal + .recording; + Object.keys(settings).forEach( + (key: keyof Omit<Partial<ISimpleRecording | IAdvancedRecording>, 'videoEncoder'>) => { + if ((settings as any)[key] === undefined) return; + if (outputInstance[key] !== settings[key]) { + (this.contexts.horizontal.recording as any)[key] = (settings as any)[key]; + } + }, + ); + } + + if ( + this.contexts.horizontal.replayBuffer && + this.state.status.horizontal.replayBuffer !== EReplayBufferState.Offline + ) { + const settings = this.outputSettingsService.getReplayBufferSettings() as Omit< + Partial<ISimpleReplayBuffer | IAdvancedReplayBuffer>, + 'videoEncoder' + >; + + const outputInstance: ISimpleReplayBuffer | IAdvancedReplayBuffer = this.contexts.horizontal + .replayBuffer; + Object.keys(settings).forEach( + (key: keyof Omit<Partial<ISimpleReplayBuffer | IAdvancedReplayBuffer>, 'videoEncoder'>) => { + if ((settings as any)[key] === undefined) return; + if (outputInstance[key] !== settings[key]) { + (this.contexts.horizontal.replayBuffer as any)[key] = (settings as any)[key]; + } + }, + ); + } + } + + private async validateOrCreateOutputInstance( mode: 'Simple' | 'Advanced', display: TDisplayType, type: 'streaming' | 'recording', @@ -1724,10 +1834,9 @@ export class StreamingService // Create new instance if it does not exist or was destroyed if (type === 'streaming') { - // TODO: create streaming instance - this.createStreaming(display, 1, true); + await this.createStreaming(display, 1); } else { - this.createRecording(display, 1, true); + await this.createRecording(display, 1); } } @@ -1741,14 +1850,48 @@ export class StreamingService AudioTrackFactory.setAtIndex(track, index); } - isAdvancedStreaming(instance: any): instance is IAdvancedStreaming { + isAdvancedStreaming( + instance: ISimpleStreaming | IAdvancedStreaming | null, + ): instance is IAdvancedStreaming { + if (!instance) return false; return 'rescaling' in instance; } - isAdvancedRecording(instance: any): instance is IAdvancedRecording { + isAdvancedRecording( + instance: ISimpleRecording | IAdvancedRecording | null, + ): instance is IAdvancedRecording { + if (!instance) return false; return 'useStreamEncoders' in instance; } + isAdvancedReplayBuffer( + instance: ISimpleReplayBuffer | IAdvancedReplayBuffer | null, + ): instance is IAdvancedReplayBuffer { + if (!instance) return false; + return 'mixer' in instance; + } + + isSimpleStreaming( + instance: ISimpleStreaming | IAdvancedStreaming | null, + ): instance is ISimpleStreaming { + if (!instance) return false; + return 'useAdvanced' in instance; + } + + isSimpleRecording( + instance: ISimpleRecording | IAdvancedRecording | null, + ): instance is ISimpleRecording { + if (!instance) return false; + return 'lowCPU' in instance; + } + + isSimpleReplayBuffer( + instance: ISimpleReplayBuffer | IAdvancedReplayBuffer | null, + ): instance is ISimpleReplayBuffer { + if (!instance) return false; + return !('mixer' in instance); + } + handleFactoryOutputError(info: EOutputSignal, display: TDisplayType) { const legacyInfo = { type: info.type as EOBSOutputType, @@ -2253,67 +2396,152 @@ export class StreamingService * @remark Will just return if the context is null * @param display - The display to destroy the output context for * @param contextType - The name of the output context to destroy + * @param confirmOffline - If true, the context will be destroyed regardless + * of the status of the other outputs. Default is false. * @returns A promise that resolves to true if the context was destroyed, false * if the context did not exist */ private destroyOutputContextIfExists( display: TDisplayType | string, contextType: keyof IOutputContext, + confirmOffline: boolean = false, ) { + this.logContexts(display as TDisplayType, 'destroyOutputContextIfExists'); + // if the context does not exist there is nothing to destroy if (!this.contexts[display] || !this.contexts[display][contextType]) return; + const offline = + this.state.status[display].streaming === EStreamingState.Offline && + this.state.status[display].recording === ERecordingState.Offline && + this.state.status[display].replayBuffer === EReplayBufferState.Offline; + + if (confirmOffline && !offline) return; + + // prevent errors by stopping an active context before destroying it if (this.state.status[display][contextType].toString() !== 'offline') { this.contexts[display][contextType].stop(); // change the status to offline for the UI switch (contextType) { case 'streaming': - this.state.status[display][contextType] = EStreamingState.Offline; - break; - case 'recording': - this.state.status[display][contextType] = ERecordingState.Offline; - break; - case 'replayBuffer': - this.state.status[display][contextType] = EReplayBufferState.Offline; - break; - } - } - - if (this.outputSettingsService.getSettings().mode === 'Advanced') { - switch (contextType) { - case 'streaming': - AdvancedStreamingFactory.destroy( - this.contexts[display][contextType] as IAdvancedStreaming, + this.SET_STREAMING_STATUS( + EStreamingState.Offline, + display as TDisplayType, + new Date().toISOString(), ); break; case 'recording': - AdvancedRecordingFactory.destroy( - this.contexts[display][contextType] as IAdvancedRecording, + this.SET_RECORDING_STATUS( + ERecordingState.Offline, + display as TDisplayType, + new Date().toISOString(), ); break; case 'replayBuffer': - AdvancedReplayBufferFactory.destroy( - this.contexts[display][contextType] as IAdvancedReplayBuffer, + this.SET_REPLAY_BUFFER_STATUS( + EReplayBufferState.Offline, + display as TDisplayType, + new Date().toISOString(), ); break; } - } else { + } + + // identify the output's factory in order to destroy the context + if (this.outputSettingsService.getSettings().mode === 'Advanced') { switch (contextType) { case 'streaming': - SimpleStreamingFactory.destroy(this.contexts[display][contextType] as ISimpleStreaming); + this.isAdvancedStreaming(this.contexts[display][contextType]) + ? AdvancedStreamingFactory.destroy( + this.contexts[display][contextType] as IAdvancedStreaming, + ) + : SimpleStreamingFactory.destroy( + this.contexts[display][contextType] as ISimpleStreaming, + ); break; case 'recording': - SimpleRecordingFactory.destroy(this.contexts[display][contextType] as ISimpleRecording); + this.isAdvancedRecording(this.contexts[display][contextType]) + ? AdvancedRecordingFactory.destroy( + this.contexts[display][contextType] as IAdvancedRecording, + ) + : SimpleRecordingFactory.destroy( + this.contexts[display][contextType] as ISimpleRecording, + ); break; case 'replayBuffer': - SimpleReplayBufferFactory.destroy( - this.contexts[display][contextType] as ISimpleReplayBuffer, - ); + this.isAdvancedReplayBuffer(this.contexts[display][contextType]) + ? AdvancedReplayBufferFactory.destroy( + this.contexts[display][contextType] as IAdvancedReplayBuffer, + ) + : SimpleReplayBufferFactory.destroy( + this.contexts[display][contextType] as ISimpleReplayBuffer, + ); break; } + + // switch (contextType) { + // case 'streaming': + // AdvancedStreamingFactory.destroy( + // this.contexts[display][contextType] as IAdvancedStreaming, + // ); + // break; + // case 'recording': + // AdvancedRecordingFactory.destroy( + // this.contexts[display][contextType] as IAdvancedRecording, + // ); + // break; + // case 'replayBuffer': + // AdvancedReplayBufferFactory.destroy( + // this.contexts[display][contextType] as IAdvancedReplayBuffer, + // ); + // break; + // } + // } else { + // switch (contextType) { + // case 'streaming': + // SimpleStreamingFactory.destroy(this.contexts[display][contextType] as ISimpleStreaming); + // break; + // case 'recording': + // SimpleRecordingFactory.destroy(this.contexts[display][contextType] as ISimpleRecording); + // break; + // case 'replayBuffer': + // SimpleReplayBufferFactory.destroy( + // this.contexts[display][contextType] as ISimpleReplayBuffer, + // ); + // break; + // } } this.contexts[display][contextType] = null; + + console.log( + 'destroyed this.contexts[display][contextType]', + display, + contextType, + this.contexts[display][contextType], + ); + } + + logContexts(display: TDisplayType, label?: string) { + const mode = this.outputSettingsService.getSettings().mode; + + console.log( + display, + [label, 'this.contexts[display].recording'].join(' '), + this.isAdvancedRecording(this.contexts[display].recording) + ? (this.contexts[display].recording as IAdvancedRecording) + : (this.contexts[display].recording as ISimpleRecording), + ); + console.log( + display, + [label, 'this.contexts[display].streaming'].join(' '), + this.contexts[display].streaming, + ); + console.log( + display, + [label, 'this.contexts[display].replayBuffer'].join(' '), + this.contexts[display].replayBuffer, + ); } @mutation() diff --git a/test/regular/recording.ts b/test/regular/recording.ts index 2a88d5d71070..9839cd26f74b 100644 --- a/test/regular/recording.ts +++ b/test/regular/recording.ts @@ -36,14 +36,11 @@ async function createRecordingFiles(advanced: boolean = false): Promise<number> // Record 0.5s video in every format for (const format of formats) { await showSettingsWindow('Output', async () => { - await sleep(2000); if (advanced) { await clickButton('Recording'); } - await sleep(2000); await setFormDropdown('Recording Format', format); - await sleep(2000); await clickButton('Done'); }); From 4c0af7fa45bcee051387ca153016dda21471b7ec Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Tue, 25 Mar 2025 17:03:05 -0400 Subject: [PATCH 18/25] Fix reference issue with Replay Buffer. WIP: Recording while streaming. --- app/services/highlighter/index.ts | 2 +- app/services/streaming/streaming.ts | 169 ++++++++++++++++------------ 2 files changed, 100 insertions(+), 71 deletions(-) diff --git a/app/services/highlighter/index.ts b/app/services/highlighter/index.ts index e49ba6b8f21d..8cb7be96b8be 100644 --- a/app/services/highlighter/index.ts +++ b/app/services/highlighter/index.ts @@ -359,7 +359,7 @@ export class HighlighterService extends PersistentStatefulService<IHighlighterSt this.streamingService.replayBufferFileWrite.subscribe(async clipPath => { const message = $t('A new Highlight has been saved. Click to edit in the Highlighter'); - this.notificationsService.actions.push({ + this.notificationsService.push({ type: ENotificationType.SUCCESS, message, action: this.jsonrpcService.createRequest(Service.getResourceId(this), 'showHighlighter'), diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index fc99098d8280..f01ec087b2ed 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -1073,7 +1073,7 @@ export class StreamingService this.state.status.horizontal.recording === ERecordingState.Offline && this.state.status.vertical.recording === ERecordingState.Offline ) { - this.toggleRecording(); + await this.toggleRecording(); } } @@ -1185,7 +1185,7 @@ export class StreamingService this.state.status.horizontal.recording === ERecordingState.Recording || this.state.status.vertical.recording === ERecordingState.Recording; if (!keepRecording && isRecording) { - this.toggleRecording(); + await this.toggleRecording(); } const keepReplaying = this.streamSettingsService.settings.keepReplayBufferStreamStops; @@ -1276,7 +1276,8 @@ export class StreamingService // stop recording vertical display // change the recording status for the loading animation this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'vertical', new Date().toISOString()); - this.contexts.vertical.recording.stop(); + this.contexts.vertical.recording.stop(true); + return; } else if ( this.state.status.horizontal.recording === ERecordingState.Recording && this.contexts.horizontal.recording !== null @@ -1284,7 +1285,22 @@ export class StreamingService // stop recording horizontal display // change the recording status for the loading animation this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'horizontal', new Date().toISOString()); - this.contexts.horizontal.recording.stop(true); + + console.log('stopping horizontal'); + + console.log('this.state.status.horizontal.recording', this.state.status.horizontal.recording); + + this.logContexts('horizontal', '*** stopRecording'); + + if (this.isAdvancedRecording(this.contexts.horizontal.recording)) { + const recording = this.contexts.horizontal.recording as IAdvancedRecording; + recording.stop(true); + } else if (this.isSimpleRecording(this.contexts.horizontal.recording)) { + const recording = this.contexts.horizontal.recording as ISimpleRecording; + recording.stop(true); + } + + return; } // start recording @@ -1310,6 +1326,8 @@ export class StreamingService this.logContexts('horizontal', '*** toggleRecording'); this.logContexts('vertical', '+++ toggleRecording'); console.log('\n\n'); + + Promise.resolve(); } /** @@ -1325,6 +1343,11 @@ export class StreamingService // recordings must have a streaming instance this.validateOrCreateOutputInstance(mode, display, 'streaming'); + const signalHandler = async (signal: EOutputSignal) => { + console.log('recording signal', signal); + await this.handleSignal(signal, display); + }; + // handle unique properties (including audio) if (mode === 'Advanced') { const recording = AdvancedRecordingFactory.create() as IAdvancedRecording; @@ -1379,10 +1402,7 @@ export class StreamingService this.contexts[display].recording.video = this.videoSettingsService.contexts[display]; // set signal handler - this.contexts[display].recording.signalHandler = async (signal: EOutputSignal) => { - console.log('recording signal', signal); - await this.handleSignal(signal, display); - }; + this.contexts[display].recording.signalHandler = signalHandler; // The replay buffer requires a recording instance. If the user is streaming but not recording, // a recording instance still needs to be created but does not need to be started. @@ -1532,6 +1552,10 @@ export class StreamingService [EOutputSignalState.Wrote]: ERecordingState.Offline, } as Dictionary<ERecordingState>)[info.signal]; + console.log( + 'received recording signal. current status is ', + this.state.status[display].recording, + ); // We received a signal we didn't recognize if (!nextState) return; @@ -1571,15 +1595,21 @@ export class StreamingService await this.markersService.exportCsv(parsedName); // Finally, all actions are completed and the recording context can be destroyed - this.destroyOutputContextIfExists(display, 'recording'); + await this.destroyOutputContextIfExists(display, 'recording'); - // Also destroy the streaming instance if it was only created for recording - // Note: this is only the case when recording without streaming in advanced mode - this.destroyOutputContextIfExists(display, 'streaming', true); + // Also destroy the streaming instance if it is not live and not being used by the replay buffer + const offline = + this.state.status[display].replayBuffer === EReplayBufferState.Offline && + this.state.status[display].streaming === EStreamingState.Offline; + + if (offline) { + await this.destroyOutputContextIfExists(display, 'streaming'); + } this.latestRecordingPath.next(fileName); } + console.log('recording status nextState', nextState); const time = new Date().toISOString(); this.SET_RECORDING_STATUS(nextState, display, time); this.recordingStatusChange.next(nextState); @@ -1639,14 +1669,15 @@ export class StreamingService // was created for the replay buffer. If the replay buffer is stopped, the recording // instance should be destroyed. - // if (offline) { - // this.destroyOutputContextIfExists(display, 'streaming'); - // } - // this.contexts[display].replayBuffer.recording = null; - // this.contexts[display].replayBuffer.streaming = null; - this.destroyOutputContextIfExists(display, 'replayBuffer'); - this.destroyOutputContextIfExists(display, 'recording'); - this.destroyOutputContextIfExists(display, 'streaming'); + const offline = + this.state.status[display].recording === ERecordingState.Offline && + this.state.status[display].streaming === EStreamingState.Offline; + + if (offline) { + await this.destroyOutputContextIfExists(display, 'replayBuffer'); + await this.destroyOutputContextIfExists(display, 'recording'); + await this.destroyOutputContextIfExists(display, 'streaming'); + } } } @@ -1659,22 +1690,26 @@ export class StreamingService } } - startReplayBuffer(display: TDisplayType = 'horizontal') { + startReplayBuffer(display: TDisplayType = 'horizontal'): void { if (this.state.status[display].replayBuffer !== EReplayBufferState.Offline) return; // change the replay buffer status for the loading animation this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Running, display); - this.createReplayBuffer(display); + Promise.resolve(this.createReplayBuffer(display)); } - createReplayBuffer(display: TDisplayType = 'horizontal') { - // this.destroyOutputContextIfExists(display, 'replayBuffer'); - + /** + * Create Replay Buffer + * @remark Create a replay buffer instance for the given display using the Factory API. + * Currently there are no cases where a replay buffer is not started immediately after creation. + * @param display - The display to create the replay buffer for + */ + private async createReplayBuffer(display: TDisplayType = 'horizontal') { const mode = this.outputSettingsService.getSettings().mode; // A replay buffer requires a recording instance and a streaming instance - this.validateOrCreateOutputInstance(mode, display, 'recording'); - this.validateOrCreateOutputInstance(mode, display, 'streaming'); + await this.validateOrCreateOutputInstance(mode, display, 'recording'); + await this.validateOrCreateOutputInstance(mode, display, 'streaming'); const settings = this.outputSettingsService.getReplayBufferSettings(); const replayBuffer = @@ -1688,39 +1723,39 @@ export class StreamingService (replayBuffer as any)[key] = (settings as any)[key]; }); - // replayBuffer.recording = - // mode === 'Advanced' - // ? (this.contexts[display].recording as IAdvancedRecording) - // : (this.contexts[display].recording as ISimpleRecording); - - // // replayBuffer.recording.signalHandler = async signal => { - // // console.log('replay buffer recording signal', signal); - // // await this.handleSignal(signal, display); - // // }; - - // replayBuffer.streaming = - // mode === 'Advanced' - // ? (this.contexts[display].streaming as IAdvancedStreaming) - // : (this.contexts[display].streaming as ISimpleStreaming); - - // // this.contexts[display].replayBuffer = - // // mode === 'Advanced' - // // ? (replayBuffer as IAdvancedReplayBuffer) - // // : (replayBuffer as ISimpleReplayBuffer); - - // if (this.isAdvancedReplayBuffer(replayBuffer)) { - // (this.contexts[display] - // .replayBuffer as IAdvancedReplayBuffer) = replayBuffer as IAdvancedReplayBuffer; - // } else { - // (this.contexts[display] - // .replayBuffer as ISimpleReplayBuffer) = replayBuffer as ISimpleReplayBuffer; - // } - - // this.contexts[display].replayBuffer.video = this.videoSettingsService.contexts[display]; - // this.contexts[display].replayBuffer.signalHandler = async signal => { - // console.log('replay buffer signal', signal); - // await this.handleSignal(signal, display); - // }; + const signalHandler = async (signal: EOutputSignal) => { + console.log('replay buffer signal', signal); + await this.handleSignal(signal, display); + }; + + if (this.isAdvancedReplayBuffer(replayBuffer)) { + const recording = this.contexts[display].recording as IAdvancedRecording; + const streaming = this.contexts[display].streaming as IAdvancedStreaming; + recording.signalHandler = signalHandler; + streaming.signalHandler = signalHandler; + replayBuffer.recording = recording; + replayBuffer.streaming = streaming; + + this.contexts[display].replayBuffer = replayBuffer as IAdvancedReplayBuffer; + } else if (this.isSimpleReplayBuffer(replayBuffer)) { + const recording = this.contexts[display].recording as ISimpleRecording; + const streaming = this.contexts[display].streaming as ISimpleStreaming; + recording.signalHandler = signalHandler; + streaming.signalHandler = signalHandler; + replayBuffer.recording = recording; + replayBuffer.streaming = streaming; + + this.contexts[display].replayBuffer = replayBuffer as ISimpleReplayBuffer; + } else { + throwStreamError( + 'UNKNOWN_STREAMING_ERROR_WITH_MESSAGE', + {}, + 'Unable to create replay buffer instance', + ); + } + + this.contexts[display].replayBuffer.video = this.videoSettingsService.contexts[display]; + this.contexts[display].replayBuffer.signalHandler = signalHandler; this.contexts[display].replayBuffer.start(); this.usageStatisticsService.recordFeatureUsage('ReplayBuffer'); @@ -1830,7 +1865,7 @@ export class StreamingService (mode === 'Simple' && isAdvancedOutputInstance) || (mode === 'Advanced' && !isAdvancedOutputInstance) ) { - this.destroyOutputContextIfExists(display, type); + await this.destroyOutputContextIfExists(display, type); } } @@ -2403,22 +2438,14 @@ export class StreamingService * @returns A promise that resolves to true if the context was destroyed, false * if the context did not exist */ - private destroyOutputContextIfExists( + private async destroyOutputContextIfExists( display: TDisplayType | string, contextType: keyof IOutputContext, - confirmOffline: boolean = false, ) { this.logContexts(display as TDisplayType, 'destroyOutputContextIfExists'); // if the context does not exist there is nothing to destroy if (!this.contexts[display] || !this.contexts[display][contextType]) return; - const offline = - this.state.status[display].streaming === EStreamingState.Offline && - this.state.status[display].recording === ERecordingState.Offline && - this.state.status[display].replayBuffer === EReplayBufferState.Offline; - - if (confirmOffline && !offline) return; - // prevent errors by stopping an active context before destroying it if (this.state.status[display][contextType].toString() !== 'offline') { this.contexts[display][contextType].stop(); @@ -2522,6 +2549,8 @@ export class StreamingService contextType, this.contexts[display][contextType], ); + + return Promise.resolve(); } logContexts(display: TDisplayType, label?: string) { From 5f26aac9970919307782fad0874625b29d462871 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Tue, 25 Mar 2025 17:28:22 -0400 Subject: [PATCH 19/25] Assign output references directly and remove logs. --- app/services/streaming/streaming.ts | 91 +++++------------------------ 1 file changed, 15 insertions(+), 76 deletions(-) diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index f01ec087b2ed..c3efcbdd6fa4 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -679,7 +679,6 @@ export class StreamingService ? 'SETTINGS_UPDATE_FAILED' : e.type || 'UNKNOWN_ERROR'; this.setError(e, platform); - console.log('handleSetupPlatformError e', e); } else { this.setError('SETTINGS_UPDATE_FAILED', platform); } @@ -1268,7 +1267,7 @@ export class StreamingService this.recordingStopped.next(); } - return; + return Promise.resolve(); } else if ( this.state.status.vertical.recording === ERecordingState.Recording && this.contexts.vertical.recording !== null @@ -1276,8 +1275,9 @@ export class StreamingService // stop recording vertical display // change the recording status for the loading animation this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'vertical', new Date().toISOString()); + this.contexts.vertical.recording.stop(true); - return; + return Promise.resolve(); } else if ( this.state.status.horizontal.recording === ERecordingState.Recording && this.contexts.horizontal.recording !== null @@ -1286,12 +1286,6 @@ export class StreamingService // change the recording status for the loading animation this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'horizontal', new Date().toISOString()); - console.log('stopping horizontal'); - - console.log('this.state.status.horizontal.recording', this.state.status.horizontal.recording); - - this.logContexts('horizontal', '*** stopRecording'); - if (this.isAdvancedRecording(this.contexts.horizontal.recording)) { const recording = this.contexts.horizontal.recording as IAdvancedRecording; recording.stop(true); @@ -1300,7 +1294,7 @@ export class StreamingService recording.stop(true); } - return; + return Promise.resolve(); } // start recording @@ -1323,9 +1317,6 @@ export class StreamingService await this.createRecording('horizontal', 1, true); } } - this.logContexts('horizontal', '*** toggleRecording'); - this.logContexts('vertical', '+++ toggleRecording'); - console.log('\n\n'); Promise.resolve(); } @@ -1351,7 +1342,6 @@ export class StreamingService // handle unique properties (including audio) if (mode === 'Advanced') { const recording = AdvancedRecordingFactory.create() as IAdvancedRecording; - const stream = this.contexts[display].streaming as IAdvancedStreaming; Object.keys(settings).forEach(key => { if ((settings as any)[key] === undefined) return; @@ -1359,7 +1349,7 @@ export class StreamingService // share the video encoder with the streaming instance if it exists if (key === 'videoEncoder') { recording.videoEncoder = - stream?.videoEncoder ?? + (this.contexts[display].streaming as IAdvancedStreaming)?.videoEncoder ?? VideoEncoderFactory.create(settings.videoEncoder, 'video-encoder'); } else { (recording as any)[key] = (settings as any)[key]; @@ -1374,26 +1364,24 @@ export class StreamingService // audio track this.createAudioTrack(index); - recording.streaming = stream; + recording.streaming = this.contexts[display].streaming as IAdvancedStreaming; this.contexts[display].recording = recording as IAdvancedRecording; } else { const recording = SimpleRecordingFactory.create() as ISimpleRecording; - const stream = this.contexts[display].streaming as ISimpleStreaming; - Object.keys(settings).forEach(key => { if ((settings as any)[key] === undefined) return; // share the video encoder with the streaming instance if it exists if (key === 'videoEncoder') { recording.videoEncoder = - stream?.videoEncoder ?? + (this.contexts[display].streaming as ISimpleStreaming)?.videoEncoder ?? VideoEncoderFactory.create(settings.videoEncoder, 'video-encoder'); } else { (recording as any)[key] = (settings as any)[key]; } }); - recording.streaming = stream; + recording.streaming = this.contexts[display].streaming as ISimpleStreaming; recording.audioEncoder = AudioEncoderFactory.create(); this.contexts[display].recording = recording as ISimpleRecording; } @@ -1411,7 +1399,6 @@ export class StreamingService this.contexts[display].recording.start(); } - this.logContexts(display, 'createRecording created '); return Promise.resolve(this.contexts[display].recording); } @@ -1488,10 +1475,6 @@ export class StreamingService this.contexts[display].streaming.start(); } - console.log( - 'createdStreaming this.contexts[display].streaming', - this.contexts[display].streaming, - ); return Promise.resolve(this.contexts[display].streaming); } @@ -1525,7 +1508,6 @@ export class StreamingService private handleStreamingSignal(info: EOutputSignal, display: TDisplayType) { // map signals to status - console.log('streaming signal info', info); // const nextState: EStreamingState = ({ // [EOBSOutputSignal.Start]: EStreamingState.Starting, @@ -1552,10 +1534,6 @@ export class StreamingService [EOutputSignalState.Wrote]: ERecordingState.Offline, } as Dictionary<ERecordingState>)[info.signal]; - console.log( - 'received recording signal. current status is ', - this.state.status[display].recording, - ); // We received a signal we didn't recognize if (!nextState) return; @@ -1594,22 +1572,20 @@ export class StreamingService } await this.markersService.exportCsv(parsedName); - // Finally, all actions are completed and the recording context can be destroyed - await this.destroyOutputContextIfExists(display, 'recording'); - - // Also destroy the streaming instance if it is not live and not being used by the replay buffer + // Only destroy instances if all outputs are offline const offline = this.state.status[display].replayBuffer === EReplayBufferState.Offline && this.state.status[display].streaming === EStreamingState.Offline; if (offline) { + await this.destroyOutputContextIfExists(display, 'replayBuffer'); + await this.destroyOutputContextIfExists(display, 'recording'); await this.destroyOutputContextIfExists(display, 'streaming'); } this.latestRecordingPath.next(fileName); } - console.log('recording status nextState', nextState); const time = new Date().toISOString(); this.SET_RECORDING_STATUS(nextState, display, time); this.recordingStatusChange.next(nextState); @@ -1650,25 +1626,6 @@ export class StreamingService code: info.code, }); - // There are a few cases where a recording and streaming instance are created for the replay buffer. - // In these cases, the created recording and streaming instances should be destroyed - // when the replay buffer is stopped. - // 1. Simple Replay Buffer: When using the replay buffer without recording or streaming, - // a simple recording instance is created for the replay buffer. - // 2. Simple Replay Buffer: When using the replay butter while streaming but not recording, - // a simple recording instance is created for the replay buffer. - // 3. Advanced Replay Buffer: When using the replay buffer without recording or streaming, - // an advanced recording instance is created for the replay buffer. This advanced recording - // instance will create an advanced streaming instance if it does not exist. - // 4. Advanced Replay Buffer: When using the replay buffer while streaming but not recording, - // a recording instance is created for the replay buffer. If the replay buffer is stopped, - // the recording instance should be destroyed. - - // THE BELOW WORKS - // In the case that the user is streaming but not recording, a recording instance - // was created for the replay buffer. If the replay buffer is stopped, the recording - // instance should be destroyed. - const offline = this.state.status[display].recording === ERecordingState.Offline && this.state.status[display].streaming === EStreamingState.Offline; @@ -1729,21 +1686,13 @@ export class StreamingService }; if (this.isAdvancedReplayBuffer(replayBuffer)) { - const recording = this.contexts[display].recording as IAdvancedRecording; - const streaming = this.contexts[display].streaming as IAdvancedStreaming; - recording.signalHandler = signalHandler; - streaming.signalHandler = signalHandler; - replayBuffer.recording = recording; - replayBuffer.streaming = streaming; + replayBuffer.recording = this.contexts[display].recording as IAdvancedRecording; + replayBuffer.streaming = this.contexts[display].streaming as IAdvancedStreaming; this.contexts[display].replayBuffer = replayBuffer as IAdvancedReplayBuffer; } else if (this.isSimpleReplayBuffer(replayBuffer)) { - const recording = this.contexts[display].recording as ISimpleRecording; - const streaming = this.contexts[display].streaming as ISimpleStreaming; - recording.signalHandler = signalHandler; - streaming.signalHandler = signalHandler; - replayBuffer.recording = recording; - replayBuffer.streaming = streaming; + replayBuffer.recording = this.contexts[display].recording as ISimpleRecording; + replayBuffer.streaming = this.contexts[display].streaming as ISimpleStreaming; this.contexts[display].replayBuffer = replayBuffer as ISimpleReplayBuffer; } else { @@ -2442,7 +2391,6 @@ export class StreamingService display: TDisplayType | string, contextType: keyof IOutputContext, ) { - this.logContexts(display as TDisplayType, 'destroyOutputContextIfExists'); // if the context does not exist there is nothing to destroy if (!this.contexts[display] || !this.contexts[display][contextType]) return; @@ -2543,19 +2491,10 @@ export class StreamingService this.contexts[display][contextType] = null; - console.log( - 'destroyed this.contexts[display][contextType]', - display, - contextType, - this.contexts[display][contextType], - ); - return Promise.resolve(); } logContexts(display: TDisplayType, label?: string) { - const mode = this.outputSettingsService.getSettings().mode; - console.log( display, [label, 'this.contexts[display].recording'].join(' '), From 501b19c6e4261a3c059e881ed5acaf49067748c3 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Tue, 25 Mar 2025 18:08:20 -0400 Subject: [PATCH 20/25] Fix recording reference issue when switching between simple and advanced. --- .../api/external-api/streaming/streaming.ts | 2 +- app/services/streaming/streaming.ts | 58 +++++++++---------- 2 files changed, 27 insertions(+), 33 deletions(-) diff --git a/app/services/api/external-api/streaming/streaming.ts b/app/services/api/external-api/streaming/streaming.ts index 9982daf46bad..42d2c33ee163 100644 --- a/app/services/api/external-api/streaming/streaming.ts +++ b/app/services/api/external-api/streaming/streaming.ts @@ -121,7 +121,7 @@ export class StreamingService implements ISerializable { /** * Toggles recording. */ - toggleRecording(): Promise<void> { + toggleRecording(): void { return this.streamingService.toggleRecording(); } diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index c3efcbdd6fa4..2c1bc891862b 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -1236,7 +1236,7 @@ export class StreamingService this.toggleRecording(); } - async toggleRecording() { + toggleRecording() { // stop recording if ( this.state.status.horizontal.recording === ERecordingState.Recording && @@ -1267,7 +1267,7 @@ export class StreamingService this.recordingStopped.next(); } - return Promise.resolve(); + return; } else if ( this.state.status.vertical.recording === ERecordingState.Recording && this.contexts.vertical.recording !== null @@ -1277,7 +1277,7 @@ export class StreamingService this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'vertical', new Date().toISOString()); this.contexts.vertical.recording.stop(true); - return Promise.resolve(); + return; } else if ( this.state.status.horizontal.recording === ERecordingState.Recording && this.contexts.horizontal.recording !== null @@ -1285,16 +1285,8 @@ export class StreamingService // stop recording horizontal display // change the recording status for the loading animation this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'horizontal', new Date().toISOString()); - - if (this.isAdvancedRecording(this.contexts.horizontal.recording)) { - const recording = this.contexts.horizontal.recording as IAdvancedRecording; - recording.stop(true); - } else if (this.isSimpleRecording(this.contexts.horizontal.recording)) { - const recording = this.contexts.horizontal.recording as ISimpleRecording; - recording.stop(true); - } - - return Promise.resolve(); + this.contexts.horizontal.recording.stop(true); + return; } // start recording @@ -1306,19 +1298,17 @@ export class StreamingService if (this.state.streamingStatus !== EStreamingState.Offline) { // In dual output mode, if the streaming status is starting then this call to toggle recording came from the function to toggle streaming. // In this case, only stream the horizontal display (don't record the horizontal display) and record the vertical display. - await this.createRecording('vertical', 2, true); + this.createRecording('vertical', 2, true); } else { // Otherwise, record both displays in dual output mode - await this.createRecording('vertical', 2, true); - await this.createRecording('horizontal', 1, true); + this.createRecording('vertical', 2, true); + this.createRecording('horizontal', 1, true); } } else { // In single output mode, recording only the horizontal display - await this.createRecording('horizontal', 1, true); + this.createRecording('horizontal', 1, true); } } - - Promise.resolve(); } /** @@ -1332,12 +1322,7 @@ export class StreamingService const settings = this.outputSettingsService.getRecordingSettings(); // recordings must have a streaming instance - this.validateOrCreateOutputInstance(mode, display, 'streaming'); - - const signalHandler = async (signal: EOutputSignal) => { - console.log('recording signal', signal); - await this.handleSignal(signal, display); - }; + await this.validateOrCreateOutputInstance(mode, display, 'streaming'); // handle unique properties (including audio) if (mode === 'Advanced') { @@ -1390,7 +1375,13 @@ export class StreamingService this.contexts[display].recording.video = this.videoSettingsService.contexts[display]; // set signal handler - this.contexts[display].recording.signalHandler = signalHandler; + this.contexts[display].recording.signalHandler = async (signal: EOutputSignal) => { + console.log('recording signal', signal); + this.logContexts(display, 'recording signal'); + console.log('\n\n'); + + await this.handleSignal(signal, display); + }; // The replay buffer requires a recording instance. If the user is streaming but not recording, // a recording instance still needs to be created but does not need to be started. @@ -1463,6 +1454,8 @@ export class StreamingService this.contexts[display].streaming.video = this.videoSettingsService.contexts[display]; this.contexts[display].streaming.signalHandler = async signal => { console.log('streaming signal', signal); + this.logContexts(display, 'streaming signal'); + console.log('\n\n'); await this.handleSignal(signal, display); }; @@ -1680,11 +1673,6 @@ export class StreamingService (replayBuffer as any)[key] = (settings as any)[key]; }); - const signalHandler = async (signal: EOutputSignal) => { - console.log('replay buffer signal', signal); - await this.handleSignal(signal, display); - }; - if (this.isAdvancedReplayBuffer(replayBuffer)) { replayBuffer.recording = this.contexts[display].recording as IAdvancedRecording; replayBuffer.streaming = this.contexts[display].streaming as IAdvancedStreaming; @@ -1704,7 +1692,12 @@ export class StreamingService } this.contexts[display].replayBuffer.video = this.videoSettingsService.contexts[display]; - this.contexts[display].replayBuffer.signalHandler = signalHandler; + this.contexts[display].replayBuffer.signalHandler = async (signal: EOutputSignal) => { + console.log('replay buffer signal', signal); + this.logContexts(display, 'replay buffer signal'); + console.log('\n\n'); + await this.handleSignal(signal, display); + }; this.contexts[display].replayBuffer.start(); this.usageStatisticsService.recordFeatureUsage('ReplayBuffer'); @@ -1805,6 +1798,7 @@ export class StreamingService if (this.contexts[display][type]) { // Check for a property that only exists on the output type's advanced instance // Note: the properties below were chosen arbitrarily + const isAdvancedOutputInstance = type === 'streaming' ? this.isAdvancedStreaming(this.contexts[display][type]) From 7c73b61d618a850829108ecd673671e465c9fe46 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Tue, 25 Mar 2025 18:40:49 -0400 Subject: [PATCH 21/25] Update replay buffer context duration. --- .../settings/output/output-settings.ts | 2 + app/services/settings/settings.ts | 4 +- app/services/streaming/streaming.ts | 91 +++---------------- 3 files changed, 15 insertions(+), 82 deletions(-) diff --git a/app/services/settings/output/output-settings.ts b/app/services/settings/output/output-settings.ts index 7002fd093b95..06b7d33606c6 100644 --- a/app/services/settings/output/output-settings.ts +++ b/app/services/settings/output/output-settings.ts @@ -479,11 +479,13 @@ export class OutputSettingsService extends Service { 'Replay Buffer', 'RecRBPrefix', ); + const suffix: string = this.settingsService.findSettingValue( output, 'Replay Buffer', 'RecRBSuffix', ); + const duration: number = this.settingsService.findSettingValue(output, 'Output', 'RecRBTime'); const useStreamEncoders = diff --git a/app/services/settings/settings.ts b/app/services/settings/settings.ts index a0a1334bff33..aa5d471f0071 100644 --- a/app/services/settings/settings.ts +++ b/app/services/settings/settings.ts @@ -260,7 +260,7 @@ export class SettingsService extends StatefulService<ISettingsServiceState> { private videoEncodingOptimizationService: VideoEncodingOptimizationService; audioRefreshed = new Subject(); - // settingsUpdated = new Subject<DeepPartial<ISettingsValues>>(); + settingsUpdated = new Subject<DeepPartial<ISettingsValues>>(); get views() { return new SettingsViews(this.state); @@ -622,7 +622,7 @@ export class SettingsService extends StatefulService<ISettingsServiceState> { this.setSettings(categoryName, formSubCategories); }); - // this.settingsUpdated.next(patch); + this.settingsUpdated.next(patch); } private setAudioSettings(settingsData: ISettingsSubCategory[]) { diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index 2c1bc891862b..b50db6e371bc 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -28,7 +28,7 @@ import { import { Inject } from 'services/core/injector'; import moment from 'moment'; import padStart from 'lodash/padStart'; -import { IOutputSettings, OutputSettingsService } from 'services/settings'; +import { IOutputSettings, OutputSettingsService, SettingsService } from 'services/settings'; import { WindowsService } from 'services/windows'; import { Subject } from 'rxjs'; import { @@ -134,15 +134,6 @@ export interface IOBSOutputSignalInfo { type TOBSOutputType = 'streaming' | 'recording' | 'replayBuffer'; interface IOutputContext { - // simpleStreaming: ISimpleStreaming; - // simpleReplayBuffer: ISimpleReplayBuffer; - // simpleRecording: ISimpleRecording; - // advancedStreaming: IAdvancedStreaming; - // advancedRecording: IAdvancedRecording; - // advancedReplayBuffer: IAdvancedReplayBuffer; - // streaming: ISimpleStreaming | IAdvancedStreaming; - // recording: ISimpleRecording | IAdvancedRecording; - // replayBuffer: ISimpleReplayBuffer | IAdvancedReplayBuffer; streaming: ISimpleStreaming | IAdvancedStreaming; recording: ISimpleRecording | IAdvancedRecording; replayBuffer: ISimpleReplayBuffer | IAdvancedReplayBuffer; @@ -166,6 +157,7 @@ export class StreamingService @Inject() private markersService: MarkersService; @Inject() private dualOutputService: DualOutputService; @Inject() private youtubeService: YoutubeService; + @Inject() private settingsService: SettingsService; streamingStatusChange = new Subject<EStreamingState>(); recordingStatusChange = new Subject<ERecordingState>(); @@ -283,9 +275,15 @@ export class StreamingService }, ); - // this.settingsService.settingsUpdated.subscribe(() => { - // this.updateOutputInstance(); - // }); + this.settingsService.settingsUpdated.subscribe(patch => { + // TODO: write a more versatile handler for settings updates + // For now, only handle updating the replay buffer duration + if (patch.Output?.RecRBTime && this.contexts.horizontal.replayBuffer) { + console.log('settingsUpdated patch', JSON.stringify(patch, null, 2)); + this.contexts.horizontal.replayBuffer.duration = patch.Output.RecRBTime; + this.logContexts('horizontal', 'replayBuffer update'); + } + }); } get views() { @@ -1725,71 +1723,6 @@ export class StreamingService this.contexts[display].replayBuffer.save(); } - private updateOutputInstance() { - if ( - this.contexts.horizontal.streaming && - this.state.status.horizontal.streaming !== EStreamingState.Offline - ) { - const settings = this.outputSettingsService.getStreamingSettings() as Omit< - Partial<ISimpleStreaming | IAdvancedStreaming>, - 'videoEncoder' - >; - const outputInstance: ISimpleStreaming | IAdvancedStreaming = this.contexts.horizontal - .streaming; - - Object.keys(settings).forEach( - (key: keyof Omit<Partial<ISimpleStreaming | IAdvancedStreaming>, 'videoEncoder'>) => { - if ((settings as any)[key] === undefined) return; - if (outputInstance[key] !== settings[key]) { - (this.contexts.horizontal.streaming as any)[key] = (settings as any)[key]; - } - }, - ); - } - - if ( - this.contexts.horizontal.recording && - this.state.status.horizontal.recording !== ERecordingState.Offline - ) { - const settings = this.outputSettingsService.getRecordingSettings() as Omit< - Partial<ISimpleRecording | IAdvancedRecording>, - 'videoEncoder' - >; - - const outputInstance: ISimpleRecording | IAdvancedRecording = this.contexts.horizontal - .recording; - Object.keys(settings).forEach( - (key: keyof Omit<Partial<ISimpleRecording | IAdvancedRecording>, 'videoEncoder'>) => { - if ((settings as any)[key] === undefined) return; - if (outputInstance[key] !== settings[key]) { - (this.contexts.horizontal.recording as any)[key] = (settings as any)[key]; - } - }, - ); - } - - if ( - this.contexts.horizontal.replayBuffer && - this.state.status.horizontal.replayBuffer !== EReplayBufferState.Offline - ) { - const settings = this.outputSettingsService.getReplayBufferSettings() as Omit< - Partial<ISimpleReplayBuffer | IAdvancedReplayBuffer>, - 'videoEncoder' - >; - - const outputInstance: ISimpleReplayBuffer | IAdvancedReplayBuffer = this.contexts.horizontal - .replayBuffer; - Object.keys(settings).forEach( - (key: keyof Omit<Partial<ISimpleReplayBuffer | IAdvancedReplayBuffer>, 'videoEncoder'>) => { - if ((settings as any)[key] === undefined) return; - if (outputInstance[key] !== settings[key]) { - (this.contexts.horizontal.replayBuffer as any)[key] = (settings as any)[key]; - } - }, - ); - } - } - private async validateOrCreateOutputInstance( mode: 'Simple' | 'Advanced', display: TDisplayType, @@ -1797,8 +1730,6 @@ export class StreamingService ) { if (this.contexts[display][type]) { // Check for a property that only exists on the output type's advanced instance - // Note: the properties below were chosen arbitrarily - const isAdvancedOutputInstance = type === 'streaming' ? this.isAdvancedStreaming(this.contexts[display][type]) From 1498c0782cdc233bd4276c898574770c55671e1f Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Wed, 26 Mar 2025 16:24:58 -0400 Subject: [PATCH 22/25] Fix replay buffer properties. --- .../settings/output/output-settings.ts | 37 ++++--------------- app/services/streaming/streaming.ts | 18 ++++----- 2 files changed, 17 insertions(+), 38 deletions(-) diff --git a/app/services/settings/output/output-settings.ts b/app/services/settings/output/output-settings.ts index 06b7d33606c6..64df24096cd9 100644 --- a/app/services/settings/output/output-settings.ts +++ b/app/services/settings/output/output-settings.ts @@ -257,7 +257,7 @@ export class OutputSettingsService extends Service { const convertedEncoderName: | EObsSimpleEncoder.x264_lowcpu - | EObsAdvancedEncoder = this.convertEncoderToNewAPI(this.getSettings().recording.encoder); + | EObsAdvancedEncoder = this.convertEncoderToNewAPI(this.getSettings().streaming.encoder); const videoEncoder: EObsAdvancedEncoder = convertedEncoderName === EObsSimpleEncoder.x264_lowcpu @@ -475,18 +475,22 @@ export class OutputSettingsService extends Service { ); const prefix: string = this.settingsService.findSettingValue( - output, + advanced, 'Replay Buffer', 'RecRBPrefix', ); const suffix: string = this.settingsService.findSettingValue( - output, + advanced, 'Replay Buffer', 'RecRBSuffix', ); - const duration: number = this.settingsService.findSettingValue(output, 'Output', 'RecRBTime'); + const duration: number = this.settingsService.findSettingValue( + output, + 'Replay Buffer', + 'RecRBTime', + ); const useStreamEncoders = this.settingsService.findSettingValue(output, 'Recording', 'RecEncoder') === 'none'; @@ -602,31 +606,6 @@ export class OutputSettingsService extends Service { duration, }; } - // simple - // recording.path = path.join(path.normalize(__dirname), '..', 'osnData'); - // recording.format = ERecordingFormat.MOV; - // recording.quality = ERecordingQuality.HighQuality; - // recording.video = obs.defaultVideoContext; - // recording.videoEncoder = - // osn.VideoEncoderFactory.create('obs_x264', 'video-encoder'); - // recording.lowCPU = true; - // recording.audioEncoder = osn.AudioEncoderFactory.create(); - // recording.overwrite = true; - // recording.noSpace = false; - - // advanced - // recording.path = path.join(path.normalize(__dirname), '..', 'osnData'); - // recording.format = ERecordingFormat.MOV; - // recording.videoEncoder = - // osn.VideoEncoderFactory.create('obs_x264', 'video-encoder'); - // recording.overwrite = true; - // recording.noSpace = false; - // recording.video = obs.defaultVideoContext; - // recording.mixer = 7; - // recording.rescaling = true; - // recording.outputWidth = 1920; - // recording.outputHeight = 1080; - // recording.useStreamEncoders = false; getAdvancedRecordingSettings() { const output = this.settingsService.state.Output.formData; diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index b50db6e371bc..9b11dd13cd6b 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -1331,9 +1331,10 @@ export class StreamingService // share the video encoder with the streaming instance if it exists if (key === 'videoEncoder') { - recording.videoEncoder = - (this.contexts[display].streaming as IAdvancedStreaming)?.videoEncoder ?? - VideoEncoderFactory.create(settings.videoEncoder, 'video-encoder'); + recording.videoEncoder = VideoEncoderFactory.create( + settings.videoEncoder, + 'video-encoder', + ); } else { (recording as any)[key] = (settings as any)[key]; } @@ -1356,9 +1357,10 @@ export class StreamingService // share the video encoder with the streaming instance if it exists if (key === 'videoEncoder') { - recording.videoEncoder = - (this.contexts[display].streaming as ISimpleStreaming)?.videoEncoder ?? - VideoEncoderFactory.create(settings.videoEncoder, 'video-encoder'); + recording.videoEncoder = VideoEncoderFactory.create( + settings.videoEncoder, + 'video-encoder', + ); } else { (recording as any)[key] = (settings as any)[key]; } @@ -1413,9 +1415,7 @@ export class StreamingService // share the video encoder with the recording instance if it exists if (key === 'videoEncoder') { - stream.videoEncoder = - this.contexts[display].recording?.videoEncoder ?? - VideoEncoderFactory.create(settings.videoEncoder, 'video-encoder'); + stream.videoEncoder = VideoEncoderFactory.create(settings.videoEncoder, 'video-encoder'); } else { (stream as any)[key] = (settings as any)[key]; } From dc35413102220c7a3287e93cfb0932d8044221a0 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Thu, 27 Mar 2025 20:03:21 -0400 Subject: [PATCH 23/25] Fix extra instances on stream with replay buffer and recording. --- app/services/streaming/streaming.ts | 123 ++++++++++------------------ 1 file changed, 45 insertions(+), 78 deletions(-) diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index 9b11dd13cd6b..c4cfe64bc7e3 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -279,9 +279,7 @@ export class StreamingService // TODO: write a more versatile handler for settings updates // For now, only handle updating the replay buffer duration if (patch.Output?.RecRBTime && this.contexts.horizontal.replayBuffer) { - console.log('settingsUpdated patch', JSON.stringify(patch, null, 2)); this.contexts.horizontal.replayBuffer.duration = patch.Output.RecRBTime; - this.logContexts('horizontal', 'replayBuffer update'); } }); } @@ -1296,15 +1294,19 @@ export class StreamingService if (this.state.streamingStatus !== EStreamingState.Offline) { // In dual output mode, if the streaming status is starting then this call to toggle recording came from the function to toggle streaming. // In this case, only stream the horizontal display (don't record the horizontal display) and record the vertical display. - this.createRecording('vertical', 2, true); + this.validateOrCreateOutputInstance('vertical', 'recording', 2); + this.contexts.vertical.recording.start(); } else { // Otherwise, record both displays in dual output mode - this.createRecording('vertical', 2, true); - this.createRecording('horizontal', 1, true); + this.validateOrCreateOutputInstance('vertical', 'recording', 2); + this.validateOrCreateOutputInstance('horizontal', 'recording', 1); + this.contexts.vertical.recording.start(); + this.contexts.horizontal.recording.start(); } } else { // In single output mode, recording only the horizontal display - this.createRecording('horizontal', 1, true); + this.validateOrCreateOutputInstance('horizontal', 'recording', 1); + this.contexts.horizontal.recording.start(); } } } @@ -1313,14 +1315,14 @@ export class StreamingService * Create a recording instance for the given display * @param display - The display to create the recording for * @param index - The index of the audio track - * @param skipStart - Whether to skip starting the recording. This is used when creating a recording instance for the replay buffer + * @param start - Whether to skip starting the recording. This is used when creating a recording instance for the replay buffer */ - private async createRecording(display: TDisplayType, index: number, start: boolean = false) { + private async createRecording(display: TDisplayType, index: number) { const mode = this.outputSettingsService.getSettings().mode; const settings = this.outputSettingsService.getRecordingSettings(); // recordings must have a streaming instance - await this.validateOrCreateOutputInstance(mode, display, 'streaming'); + await this.validateOrCreateOutputInstance(display, 'streaming', index); // handle unique properties (including audio) if (mode === 'Advanced') { @@ -1376,20 +1378,9 @@ export class StreamingService // set signal handler this.contexts[display].recording.signalHandler = async (signal: EOutputSignal) => { - console.log('recording signal', signal); - this.logContexts(display, 'recording signal'); - console.log('\n\n'); - await this.handleSignal(signal, display); }; - // The replay buffer requires a recording instance. If the user is streaming but not recording, - // a recording instance still needs to be created but does not need to be started. - if (start) { - // start recording - this.contexts[display].recording.start(); - } - return Promise.resolve(this.contexts[display].recording); } @@ -1397,9 +1388,11 @@ export class StreamingService * Create a streaming instance for the given display * @param display - The display to create the streaming for * @param index - The index of the audio track - * @param skipStart - Whether to skip starting the streaming. This is used when creating a streaming instance for advanced recording + * @param start - Whether to skip starting the streaming. This is used when creating a streaming instance for advanced recording */ private async createStreaming(display: TDisplayType, index: number, start: boolean = false) { + console.log('========> CREATE STREAMING <========'); + const mode = this.outputSettingsService.getSettings().mode; const settings = this.outputSettingsService.getStreamingSettings(); @@ -1451,9 +1444,6 @@ export class StreamingService this.contexts[display].streaming.video = this.videoSettingsService.contexts[display]; this.contexts[display].streaming.signalHandler = async signal => { - console.log('streaming signal', signal); - this.logContexts(display, 'streaming signal'); - console.log('\n\n'); await this.handleSignal(signal, display); }; @@ -1656,8 +1646,8 @@ export class StreamingService const mode = this.outputSettingsService.getSettings().mode; // A replay buffer requires a recording instance and a streaming instance - await this.validateOrCreateOutputInstance(mode, display, 'recording'); - await this.validateOrCreateOutputInstance(mode, display, 'streaming'); + await this.validateOrCreateOutputInstance(display, 'recording', 1); + await this.validateOrCreateOutputInstance(display, 'streaming', 1); const settings = this.outputSettingsService.getReplayBufferSettings(); const replayBuffer = @@ -1691,9 +1681,6 @@ export class StreamingService this.contexts[display].replayBuffer.video = this.videoSettingsService.contexts[display]; this.contexts[display].replayBuffer.signalHandler = async (signal: EOutputSignal) => { - console.log('replay buffer signal', signal); - this.logContexts(display, 'replay buffer signal'); - console.log('\n\n'); await this.handleSignal(signal, display); }; @@ -1724,33 +1711,45 @@ export class StreamingService } private async validateOrCreateOutputInstance( - mode: 'Simple' | 'Advanced', display: TDisplayType, type: 'streaming' | 'recording', + index: number, ) { - if (this.contexts[display][type]) { - // Check for a property that only exists on the output type's advanced instance - const isAdvancedOutputInstance = - type === 'streaming' - ? this.isAdvancedStreaming(this.contexts[display][type]) - : this.isAdvancedRecording(this.contexts[display][type]); + const mode = this.outputSettingsService.getSettings().mode; + const validOutput = this.validateOutputInstance(mode, display, type); - if ( - (mode === 'Simple' && isAdvancedOutputInstance) || - (mode === 'Advanced' && !isAdvancedOutputInstance) - ) { - await this.destroyOutputContextIfExists(display, type); - } - } + // If the instance matches the mode, return to validate it + if (validOutput) return; + + await this.destroyOutputContextIfExists(display, type); - // Create new instance if it does not exist or was destroyed if (type === 'streaming') { - await this.createStreaming(display, 1); + await this.createStreaming(display, index); } else { - await this.createRecording(display, 1); + await this.createRecording(display, index); } } + private validateOutputInstance( + mode: 'Simple' | 'Advanced', + display: TDisplayType, + type: 'streaming' | 'recording', + ) { + if (!this.contexts[display][type]) return false; + + const isAdvancedOutput = + type === 'streaming' + ? this.isAdvancedStreaming(this.contexts[display][type]) + : this.isAdvancedRecording(this.contexts[display][type]); + + const isSimpleOutput = + type === 'streaming' + ? this.isSimpleStreaming(this.contexts[display][type]) + : this.isSimpleRecording(this.contexts[display][type]); + + return (mode === 'Advanced' && isAdvancedOutput) || (mode === 'Simple' && isSimpleOutput); + } + /** * Create an audio track * @param index - index of the audio track to create @@ -2380,38 +2379,6 @@ export class StreamingService ); break; } - - // switch (contextType) { - // case 'streaming': - // AdvancedStreamingFactory.destroy( - // this.contexts[display][contextType] as IAdvancedStreaming, - // ); - // break; - // case 'recording': - // AdvancedRecordingFactory.destroy( - // this.contexts[display][contextType] as IAdvancedRecording, - // ); - // break; - // case 'replayBuffer': - // AdvancedReplayBufferFactory.destroy( - // this.contexts[display][contextType] as IAdvancedReplayBuffer, - // ); - // break; - // } - // } else { - // switch (contextType) { - // case 'streaming': - // SimpleStreamingFactory.destroy(this.contexts[display][contextType] as ISimpleStreaming); - // break; - // case 'recording': - // SimpleRecordingFactory.destroy(this.contexts[display][contextType] as ISimpleRecording); - // break; - // case 'replayBuffer': - // SimpleReplayBufferFactory.destroy( - // this.contexts[display][contextType] as ISimpleReplayBuffer, - // ); - // break; - // } } this.contexts[display][contextType] = null; From ece5456188d0712566151b3b0ccc7563b9785d92 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Thu, 27 Mar 2025 20:45:58 -0400 Subject: [PATCH 24/25] Fix for replay buffer. --- app/services/streaming/streaming.ts | 41 +++++++++++++++-------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index c4cfe64bc7e3..205172e8ddaa 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -1294,19 +1294,15 @@ export class StreamingService if (this.state.streamingStatus !== EStreamingState.Offline) { // In dual output mode, if the streaming status is starting then this call to toggle recording came from the function to toggle streaming. // In this case, only stream the horizontal display (don't record the horizontal display) and record the vertical display. - this.validateOrCreateOutputInstance('vertical', 'recording', 2); - this.contexts.vertical.recording.start(); + this.validateOrCreateOutputInstance('vertical', 'recording', 2, true); } else { // Otherwise, record both displays in dual output mode - this.validateOrCreateOutputInstance('vertical', 'recording', 2); - this.validateOrCreateOutputInstance('horizontal', 'recording', 1); - this.contexts.vertical.recording.start(); - this.contexts.horizontal.recording.start(); + this.validateOrCreateOutputInstance('vertical', 'recording', 2, true); + this.validateOrCreateOutputInstance('horizontal', 'recording', 1, true); } } else { // In single output mode, recording only the horizontal display - this.validateOrCreateOutputInstance('horizontal', 'recording', 1); - this.contexts.horizontal.recording.start(); + this.validateOrCreateOutputInstance('horizontal', 'recording', 1, true); } } } @@ -1315,9 +1311,8 @@ export class StreamingService * Create a recording instance for the given display * @param display - The display to create the recording for * @param index - The index of the audio track - * @param start - Whether to skip starting the recording. This is used when creating a recording instance for the replay buffer */ - private async createRecording(display: TDisplayType, index: number) { + private async createRecording(display: TDisplayType, index: number, start?: boolean) { const mode = this.outputSettingsService.getSettings().mode; const settings = this.outputSettingsService.getRecordingSettings(); @@ -1381,6 +1376,10 @@ export class StreamingService await this.handleSignal(signal, display); }; + if (start) { + this.contexts[display].recording.start(); + } + return Promise.resolve(this.contexts[display].recording); } @@ -1388,11 +1387,8 @@ export class StreamingService * Create a streaming instance for the given display * @param display - The display to create the streaming for * @param index - The index of the audio track - * @param start - Whether to skip starting the streaming. This is used when creating a streaming instance for advanced recording */ - private async createStreaming(display: TDisplayType, index: number, start: boolean = false) { - console.log('========> CREATE STREAMING <========'); - + private async createStreaming(display: TDisplayType, index: number, start?: boolean) { const mode = this.outputSettingsService.getSettings().mode; const settings = this.outputSettingsService.getStreamingSettings(); @@ -1588,10 +1584,6 @@ export class StreamingService return; } - const time = new Date().toISOString(); - this.SET_REPLAY_BUFFER_STATUS(nextState, display, time); - this.replayBufferStatusChange.next(nextState); - if (info.signal === EOBSOutputSignal.Wrote) { this.usageStatisticsService.recordAnalyticsEvent('ReplayBufferStatus', { status: 'wrote', @@ -1617,6 +1609,10 @@ export class StreamingService await this.destroyOutputContextIfExists(display, 'streaming'); } } + + const time = new Date().toISOString(); + this.SET_REPLAY_BUFFER_STATUS(nextState, display, time); + this.replayBufferStatusChange.next(nextState); } splitFile(display: TDisplayType = 'horizontal') { @@ -1714,19 +1710,24 @@ export class StreamingService display: TDisplayType, type: 'streaming' | 'recording', index: number, + start?: boolean, ) { const mode = this.outputSettingsService.getSettings().mode; const validOutput = this.validateOutputInstance(mode, display, type); // If the instance matches the mode, return to validate it + if (validOutput && start) { + this.contexts[display][type]?.start(); + return; + } if (validOutput) return; await this.destroyOutputContextIfExists(display, type); if (type === 'streaming') { - await this.createStreaming(display, index); + await this.createStreaming(display, index, start); } else { - await this.createRecording(display, index); + await this.createRecording(display, index, start); } } From d6b2483d71f4610c377dc3e64c0d397c3301f442 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Fri, 28 Mar 2025 15:58:18 -0400 Subject: [PATCH 25/25] Comment out dual output recording. --- app/services/streaming/streaming.ts | 158 +++++++++++++++++----------- 1 file changed, 97 insertions(+), 61 deletions(-) diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index 205172e8ddaa..3ae1e1da5628 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -1233,48 +1233,11 @@ export class StreamingService } toggleRecording() { - // stop recording + /** + * START SINGLE OUTPUT RECORDING + * Note: Comment out the below and comment in the dual output recording code block to enable dual output recording + */ if ( - this.state.status.horizontal.recording === ERecordingState.Recording && - this.state.status.vertical.recording === ERecordingState.Recording - ) { - // stop recording both displays - let time = new Date().toISOString(); - - if (this.contexts.vertical.recording !== null) { - const recordingStopped = this.recordingStopped.subscribe(async () => { - await new Promise(resolve => - // sleep for 2 seconds to allow a different time stamp to be generated - // because the recording history uses the time stamp as keys - // if the same time stamp is used, the entry will be replaced in the recording history - setTimeout(() => { - time = new Date().toISOString(); - this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'horizontal', time); - if (this.contexts.horizontal.recording !== null) { - this.contexts.horizontal.recording.stop(); - } - }, 2000), - ); - recordingStopped.unsubscribe(); - }); - - this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'vertical', time); - this.contexts.vertical.recording.stop(); - this.recordingStopped.next(); - } - - return; - } else if ( - this.state.status.vertical.recording === ERecordingState.Recording && - this.contexts.vertical.recording !== null - ) { - // stop recording vertical display - // change the recording status for the loading animation - this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'vertical', new Date().toISOString()); - - this.contexts.vertical.recording.stop(true); - return; - } else if ( this.state.status.horizontal.recording === ERecordingState.Recording && this.contexts.horizontal.recording !== null ) { @@ -1283,28 +1246,101 @@ export class StreamingService this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'horizontal', new Date().toISOString()); this.contexts.horizontal.recording.stop(true); return; + } else if (this.state.status.horizontal.recording === ERecordingState.Offline) { + this.validateOrCreateOutputInstance('horizontal', 'recording', 1, true); + } else { + throwStreamError( + 'UNKNOWN_STREAMING_ERROR_WITH_MESSAGE', + {}, + 'Unable to create replay buffer instance', + ); } - // start recording - if ( - this.state.status.horizontal.recording === ERecordingState.Offline && - this.state.status.vertical.recording === ERecordingState.Offline - ) { - if (this.views.isDualOutputMode) { - if (this.state.streamingStatus !== EStreamingState.Offline) { - // In dual output mode, if the streaming status is starting then this call to toggle recording came from the function to toggle streaming. - // In this case, only stream the horizontal display (don't record the horizontal display) and record the vertical display. - this.validateOrCreateOutputInstance('vertical', 'recording', 2, true); - } else { - // Otherwise, record both displays in dual output mode - this.validateOrCreateOutputInstance('vertical', 'recording', 2, true); - this.validateOrCreateOutputInstance('horizontal', 'recording', 1, true); - } - } else { - // In single output mode, recording only the horizontal display - this.validateOrCreateOutputInstance('horizontal', 'recording', 1, true); - } - } + /** + * END SINGLE OUTPUT RECORDING + */ + + /** + * START DUAL OUTPUT RECORDING BELOW + * Note: Comment in the below and comment out the single output recording code block to enable dual output recording + */ + + // stop recording + // if ( + // this.state.status.horizontal.recording === ERecordingState.Recording && + // this.state.status.vertical.recording === ERecordingState.Recording + // ) { + // // stop recording both displays + // let time = new Date().toISOString(); + + // if (this.contexts.vertical.recording !== null) { + // const recordingStopped = this.recordingStopped.subscribe(async () => { + // await new Promise(resolve => + // // sleep for 2 seconds to allow a different time stamp to be generated + // // because the recording history uses the time stamp as keys + // // if the same time stamp is used, the entry will be replaced in the recording history + // setTimeout(() => { + // time = new Date().toISOString(); + // this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'horizontal', time); + // if (this.contexts.horizontal.recording !== null) { + // this.contexts.horizontal.recording.stop(); + // } + // }, 2000), + // ); + // recordingStopped.unsubscribe(); + // }); + + // this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'vertical', time); + // this.contexts.vertical.recording.stop(); + // this.recordingStopped.next(); + // } + + // return; + // } else if ( + // this.state.status.vertical.recording === ERecordingState.Recording && + // this.contexts.vertical.recording !== null + // ) { + // // stop recording vertical display + // // change the recording status for the loading animation + // this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'vertical', new Date().toISOString()); + + // this.contexts.vertical.recording.stop(true); + // return; + // } else if ( + // this.state.status.horizontal.recording === ERecordingState.Recording && + // this.contexts.horizontal.recording !== null + // ) { + // // stop recording horizontal display + // // change the recording status for the loading animation + // this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'horizontal', new Date().toISOString()); + // this.contexts.horizontal.recording.stop(true); + // return; + // } + + // // start recording + // if ( + // this.state.status.horizontal.recording === ERecordingState.Offline && + // this.state.status.vertical.recording === ERecordingState.Offline + // ) { + // if (this.views.isDualOutputMode) { + // if (this.state.streamingStatus !== EStreamingState.Offline) { + // // In dual output mode, if the streaming status is starting then this call to toggle recording came from the function to toggle streaming. + // // In this case, only stream the horizontal display (don't record the horizontal display) and record the vertical display. + // this.validateOrCreateOutputInstance('vertical', 'recording', 2, true); + // } else { + // // Otherwise, record both displays in dual output mode + // this.validateOrCreateOutputInstance('vertical', 'recording', 2, true); + // this.validateOrCreateOutputInstance('horizontal', 'recording', 1, true); + // } + // } else { + // // In single output mode, recording only the horizontal display + // this.validateOrCreateOutputInstance('horizontal', 'recording', 1, true); + // } + // } + + /** + * END DUAL OUTPUT RECORDING + */ } /**