diff --git a/app/components-react/pages/RecordingHistory.tsx b/app/components-react/pages/RecordingHistory.tsx
index 03cecb60fdb9..cc91a5cb5a05 100644
--- a/app/components-react/pages/RecordingHistory.tsx
+++ b/app/components-react/pages/RecordingHistory.tsx
@@ -192,7 +192,10 @@ export function RecordingHistory() {
{formattedTimestamp(recording.timestamp)}
- showFile(recording.filename)} className={styles.filename}>
+ showFile(recording.filename)}
+ className={cx(styles.filename, 'file')}
+ >
{recording.filename}
diff --git a/app/components-react/root/StartStreamingButton.tsx b/app/components-react/root/StartStreamingButton.tsx
index 33b0bfcbafe5..34f159719d4b 100644
--- a/app/components-react/root/StartStreamingButton.tsx
+++ b/app/components-react/root/StartStreamingButton.tsx
@@ -18,7 +18,8 @@ export default function StartStreamingButton(p: { disabled?: boolean }) {
} = Services;
const { streamingStatus, delayEnabled, delaySeconds } = useVuex(() => ({
- streamingStatus: StreamingService.state.streamingStatus,
+ streamingStatus:
+ StreamingService.state.streamingStatus || StreamingService.state.verticalStreamingStatus,
delayEnabled: StreamingService.views.delayEnabled,
delaySeconds: StreamingService.views.delaySeconds,
}));
diff --git a/app/components-react/root/StudioEditor.m.less b/app/components-react/root/StudioEditor.m.less
index b93fde82744e..647e528a5f6a 100644
--- a/app/components-react/root/StudioEditor.m.less
+++ b/app/components-react/root/StudioEditor.m.less
@@ -245,6 +245,21 @@
text-decoration: none;
}
}
+
+ .stream-icon {
+ margin-right: 0px;
+ color: var(--icon-active);
+ }
+
+ .record-icon {
+ color: var(--red);
+ font-size: 18px;
+ margin-top: 3px;
+ }
+
+ .hidden {
+ opacity: 0;
+ }
}
.progress-bar {
diff --git a/app/components-react/root/StudioEditor.tsx b/app/components-react/root/StudioEditor.tsx
index 361844b859fc..03b7c85f9295 100644
--- a/app/components-react/root/StudioEditor.tsx
+++ b/app/components-react/root/StudioEditor.tsx
@@ -347,30 +347,58 @@ function StudioModeControls(p: { stacked: boolean }) {
}
function DualOutputControls(p: { stacked: boolean }) {
+ const { SettingsService, DualOutputService, StreamingService } = Services;
function openSettingsWindow() {
- Services.SettingsService.actions.showSettings('Video');
+ SettingsService.actions.showSettings('Video');
}
- const showHorizontal = Services.DualOutputService.views.showHorizontalDisplay;
- const showVertical =
- Services.DualOutputService.views.showVerticalDisplay &&
- !Services.StreamingService.state.selectiveRecording;
+ const v = useVuex(() => ({
+ showHorizontal: DualOutputService.views.showHorizontalDisplay,
+ showVertical:
+ DualOutputService.views.showVerticalDisplay && !StreamingService.state.selectiveRecording,
+ isHorizontalRecording: StreamingService.views.isHorizontalRecording,
+ isVerticalRecording: StreamingService.views.isVerticalRecording,
+ isHorizontalStreaming: StreamingService.views.isStreaming,
+ isVerticalStreaming: StreamingService.views.isVerticalStreaming,
+ }));
+
+ const horizontalIconVisible = v.isHorizontalStreaming || v.isHorizontalRecording;
+ const verticalIconVisible = v.isVerticalStreaming || v.isVerticalRecording;
+
+ /**
+ * Note for the streaming and recording icons:
+ * To maintain the horizontal and vertical header icon and text positioning centered,
+ * conditionally change the opacity of the streaming and recording icons.
+ * For the horizontal recording, to maintain the same margin of the streaming and recording icons
+ * swap the icons shown conditionally so that when only recording, the recording icon shows next to
+ * the header text.
+ */
return (
+
Streamlabs Ultra.": "Upgrade your stream with premium overlays with Streamlabs Ultra."
+ "Vertical Recording Only": "Vertical Recording Only",
+ "Upgrade your stream with premium overlays with Streamlabs Ultra.": "Upgrade your stream with premium overlays with Streamlabs Ultra.",
+ "Switch primary destination to horizontal output to proceed or toggle vertical recording off.": "Switch primary destination to horizontal output to proceed or toggle vertical recording off.",
+ "High, Medium File Size": "High, Medium File Size",
+ "Indistinguishable, Large File Size": "Indistinguishable, Large File Size",
+ "Lossless, Tremendously Large File Size": "Lossless, Tremendously Large File Size",
+ "A new %{displayType} Recording has been completed. Click for more info": "A new %{displayType} Recording has been completed. Click for more info"
}
diff --git a/app/services/api/external-api/streaming/streaming.ts b/app/services/api/external-api/streaming/streaming.ts
index a54c6eb89324..08b5266521a5 100644
--- a/app/services/api/external-api/streaming/streaming.ts
+++ b/app/services/api/external-api/streaming/streaming.ts
@@ -17,12 +17,18 @@ enum EStreamingState {
/**
* Possible recording states.
+ * @remark For consistency with the values in the new API, the properties `start` and `stop` are added.
+ * The old API has some different values. Because we use the old API for single output and the new API
+ * for dual output, we currently keep both as options. After fully migrating to the new API, the old API
+ * values will be removed.
*/
enum ERecordingState {
Offline = 'offline',
Starting = 'starting',
+ Start = 'start',
Recording = 'recording',
Stopping = 'stopping',
+ Stop = 'stop',
Wrote = 'wrote',
}
diff --git a/app/services/dual-output/dual-output.ts b/app/services/dual-output/dual-output.ts
index a8c24ad89e27..b9f170171f0e 100644
--- a/app/services/dual-output/dual-output.ts
+++ b/app/services/dual-output/dual-output.ts
@@ -12,7 +12,7 @@ import { EPlaceType } from 'services/editor-commands/commands/reorder-nodes';
import { EditorCommandsService } from 'services/editor-commands';
import { Subject } from 'rxjs';
import { TOutputOrientation } from 'services/restream';
-import { IVideoInfo } from 'obs-studio-node';
+import { IVideoInfo, ERecordingQuality } from 'obs-studio-node';
import { ICustomStreamDestination, StreamSettingsService } from 'services/settings/streaming';
import {
ISceneCollectionsManifestEntry,
@@ -21,7 +21,7 @@ import {
import { UserService } from 'services/user';
import { SelectionService } from 'services/selection';
import { StreamingService } from 'services/streaming';
-import { SettingsService } from 'services/settings';
+import { OutputSettingsService, SettingsService } from 'services/settings';
import { RunInLoadingMode } from 'services/app/app-decorators';
interface IDisplayVideoSettings {
@@ -33,15 +33,25 @@ interface IDisplayVideoSettings {
vertical: boolean;
};
}
+
+interface IRecordingQuality {
+ singleOutput: ERecordingQuality;
+ dualOutput: ERecordingQuality;
+}
interface IDualOutputServiceState {
displays: TDisplayType[];
platformSettings: TDualOutputPlatformSettings;
destinationSettings: Dictionary;
dualOutputMode: boolean;
+ recordVertical: boolean;
+ streamVertical: boolean;
videoSettings: IDisplayVideoSettings;
+ recordingQuality: IRecordingQuality;
isLoading: boolean;
}
+export type TStreamMode = 'single' | 'dual';
+
class DualOutputViews extends ViewHandler {
@Inject() private scenesService: ScenesService;
@Inject() private videoSettingsService: VideoSettingsService;
@@ -60,6 +70,14 @@ class DualOutputViews extends ViewHandler {
return this.state.dualOutputMode;
}
+ get recordVertical(): boolean {
+ return this.state.recordVertical;
+ }
+
+ get streamVertical(): boolean {
+ return this.state.streamVertical;
+ }
+
get activeCollection(): ISceneCollectionsManifestEntry {
return this.sceneCollectionsService.activeCollection;
}
@@ -142,6 +160,10 @@ class DualOutputViews extends ViewHandler {
return this.activeDisplays.vertical && !this.activeDisplays.horizontal;
}
+ get recordingQuality() {
+ return this.state.recordingQuality;
+ }
+
getPlatformDisplay(platform: TPlatform) {
return this.state.platformSettings[platform].display;
}
@@ -250,7 +272,7 @@ class DualOutputViews extends ViewHandler {
const verticalHasDestinations =
platformDisplays.vertical.length > 0 || destinationDisplays.vertical.length > 0;
- return horizontalHasDestinations && verticalHasDestinations;
+ return this.state.recordVertical || (horizontalHasDestinations && verticalHasDestinations);
}
/**
@@ -279,12 +301,15 @@ export class DualOutputService extends PersistentStatefulService {
}
get shouldGoLiveWithRestream() {
- return this.streamInfo.isMultiplatformMode || this.streamInfo.isDualOutputMode;
+ const verticalSecondDestination = this.streamingService.views.isVerticalSecondDestination;
+ return (
+ this.streamInfo.isMultiplatformMode ||
+ (this.streamInfo.isDualOutputMode && !verticalSecondDestination)
+ );
}
/**
diff --git a/app/services/settings/output/output-settings.ts b/app/services/settings/output/output-settings.ts
index 1b446463e78a..59be63455f8a 100644
--- a/app/services/settings/output/output-settings.ts
+++ b/app/services/settings/output/output-settings.ts
@@ -4,8 +4,8 @@ import { VideoSettingsService } from 'services/settings-v2/video';
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 { AudioService, DualOutputService } from 'app-services';
+import { ERecordingQuality, ERecordingFormat } from 'obs-studio-node';
/**
* list of encoders for simple mode
@@ -185,6 +185,7 @@ export class OutputSettingsService extends Service {
@Inject() private audioService: AudioService;
@Inject() private videoSettingsService: VideoSettingsService;
@Inject() private highlighterService: HighlighterService;
+ @Inject() private dualOutputService: DualOutputService;
/**
* returns unified settings for the Streaming and Recording encoder
@@ -230,6 +231,119 @@ export class OutputSettingsService extends Service {
};
}
+ getRecordingQuality() {
+ const output = this.settingsService.state.Output.formData;
+ return this.settingsService.findSettingValue(output, 'Recording', 'RecQuality');
+ }
+
+ 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;
+
+ let oldQualityName = this.settingsService.findSettingValue(output, 'Recording', 'RecQuality');
+ // brittle, remove when backend fix implemented
+ if (oldQualityName === 'Stream') {
+ oldQualityName = this.dualOutputService.views.recordingQuality;
+ }
+ 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 +598,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 357ce89c956e..15b1806d34dc 100644
--- a/app/services/streaming/streaming-api.ts
+++ b/app/services/streaming/streaming-api.ts
@@ -9,6 +9,7 @@ import { ITiktokStartStreamOptions } from '../platforms/tiktok';
import { ITrovoStartStreamOptions } from '../platforms/trovo';
import { IVideo } from 'obs-studio-node';
import { ITwitterStartStreamOptions } from 'services/platforms/twitter';
+import { TPlatform } from 'services/platforms';
export enum EStreamingState {
Offline = 'offline',
@@ -79,6 +80,7 @@ export interface IGoLiveSettings extends IStreamSettings {
youtube?: Partial;
facebook?: Partial;
};
+ primaryPlatform?: TPlatform;
}
export interface IPlatformFlags {
@@ -89,9 +91,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-view.ts b/app/services/streaming/streaming-view.ts
index ce642e028753..17dc5eb5b127 100644
--- a/app/services/streaming/streaming-view.ts
+++ b/app/services/streaming/streaming-view.ts
@@ -153,10 +153,20 @@ export class StreamInfoView extends ViewHandler {
* Returns a list of enabled for streaming platforms from the given settings object
*/
getEnabledPlatforms(platforms: IStreamSettings['platforms']): TPlatform[] {
- return Object.keys(platforms).filter(
+ const enabled = Object.keys(platforms).filter(
(platform: TPlatform) =>
this.linkedPlatforms.includes(platform) && platforms[platform]?.enabled,
) as TPlatform[];
+
+ // currently in dual output mode, when recording the vertical display
+ // it cannot also be streamed so does not need to be set up
+ if (this.isDualOutputMode && this.dualOutputView.recordVertical) {
+ return enabled.filter(
+ platform => this.dualOutputView.getPlatformDisplay(platform) === 'horizontal',
+ );
+ }
+
+ return enabled;
}
/**
@@ -178,6 +188,14 @@ export class StreamInfoView extends ViewHandler {
return this.dualOutputView.dualOutputMode && this.userView.isLoggedIn;
}
+ get isVerticalSecondDestination(): boolean {
+ return (
+ this.isDualOutputMode &&
+ this.dualOutputView.recordVertical &&
+ this.streamingState.streamingStatus !== EStreamingState.Offline
+ );
+ }
+
get shouldMultistreamDisplay(): { horizontal: boolean; vertical: boolean } {
const numHorizontal =
this.activeDisplayPlatforms.horizontal.length +
@@ -278,6 +296,7 @@ export class StreamInfoView extends ViewHandler {
advancedMode: !!this.streamSettingsView.state.goLiveSettings?.advancedMode,
optimizedProfile: undefined,
customDestinations: savedGoLiveSettings?.customDestinations || [],
+ primaryPlatform: this.userView.state.auth?.primaryPlatform,
};
}
@@ -467,10 +486,22 @@ export class StreamInfoView extends ViewHandler {
return this.streamingState.streamingStatus !== EStreamingState.Offline;
}
+ get isVerticalStreaming() {
+ return this.streamingState.verticalStreamingStatus !== EStreamingState.Offline;
+ }
+
get isRecording() {
+ return this.isHorizontalRecording || this.isVerticalRecording;
+ }
+
+ get isHorizontalRecording() {
return this.streamingState.recordingStatus !== ERecordingState.Offline;
}
+ get isVerticalRecording() {
+ return this.streamingState.verticalRecordingStatus !== ERecordingState.Offline;
+ }
+
get isReplayBufferActive() {
return this.streamingState.replayBufferStatus !== EReplayBufferState.Offline;
}
diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index bbeae310dd6a..ea2aaea77e3d 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -1,10 +1,23 @@
import Vue from 'vue';
import { mutation, StatefulService } from 'services/core/stateful-service';
-import { EOutputCode, Global, NodeObs } from '../../../obs-api';
+import {
+ EOutputCode,
+ Global,
+ NodeObs,
+ AudioEncoderFactory,
+ SimpleRecordingFactory,
+ VideoEncoderFactory,
+ ISimpleRecording,
+ IAdvancedRecording,
+ AdvancedRecordingFactory,
+ EOutputSignal,
+ AudioTrackFactory,
+ ERecordingQuality,
+} from '../../../obs-api';
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 {
@@ -45,7 +58,7 @@ import * as remote from '@electron/remote';
import { RecordingModeService } from 'services/recording-mode';
import { MarkersService } from 'services/markers';
import { byOS, OS } from 'util/operating-systems';
-import { DualOutputService } from 'services/dual-output';
+import { DualOutputService, TStreamMode } from 'services/dual-output';
enum EOBSOutputType {
Streaming = 'streaming',
@@ -65,6 +78,13 @@ enum EOBSOutputSignal {
WriteError = 'writing_error',
}
+enum EOutputSignalState {
+ Start = 'start',
+ Stop = 'stop',
+ Stopping = 'stopping',
+ Wrote = 'wrote',
+}
+
interface IOBSOutputSignalInfo {
type: EOBSOutputType;
signal: EOBSOutputSignal;
@@ -90,6 +110,7 @@ export class StreamingService
@Inject() private videoSettingsService: VideoSettingsService;
@Inject() private markersService: MarkersService;
@Inject() private dualOutputService: DualOutputService;
+ @Inject() private settingsService: SettingsService;
streamingStatusChange = new Subject();
recordingStatusChange = new Subject();
@@ -100,17 +121,25 @@ export class StreamingService
// Dummy subscription for stream deck
streamingStateChange = new Subject();
+ private recordingStopped = new Subject();
powerSaveId: number;
private resolveStartStreaming: Function = () => {};
private rejectStartStreaming: Function = () => {};
+ private horizontalRecording: ISimpleRecording | IAdvancedRecording | null = null;
+ private verticalRecording: ISimpleRecording | IAdvancedRecording | null = 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,
@@ -724,7 +753,10 @@ export class StreamingService
}
get isRecording() {
- return this.state.recordingStatus !== ERecordingState.Offline;
+ return (
+ this.state.recordingStatus !== ERecordingState.Offline ||
+ this.state.verticalRecordingStatus !== ERecordingState.Offline
+ );
}
get isReplayBufferActive() {
@@ -792,45 +824,78 @@ 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
+ if (this.dualOutputService.views.recordVertical) {
+ // stream horizontal and record vertical
+ const horizontalContext = this.videoSettingsService.contexts.horizontal;
+ NodeObs.OBS_service_setVideoInfo(horizontalContext, 'horizontal');
- const horizontalContext = this.videoSettingsService.contexts.horizontal;
- const verticalContext = this.videoSettingsService.contexts.vertical;
+ const signalChanged = this.signalInfoChanged.subscribe(
+ async (signalInfo: IOBSOutputSignalInfo) => {
+ if (signalInfo.service === 'default') {
+ if (signalInfo.signal === EOBSOutputSignal.Start) {
+ // Refactor when migrate to new API, sleep to allow state to update
+ await new Promise(resolve => setTimeout(resolve, 300));
+ this.toggleRecording();
+ signalChanged.unsubscribe();
+ }
+ }
+ },
+ );
+ NodeObs.OBS_service_startStreaming('horizontal');
+ } else {
+ // stream horizontal and stream vertical
+ const horizontalContext = this.videoSettingsService.contexts.horizontal;
+ const verticalContext = this.videoSettingsService.contexts.vertical;
- NodeObs.OBS_service_setVideoInfo(horizontalContext, 'horizontal');
- NodeObs.OBS_service_setVideoInfo(verticalContext, 'vertical');
+ NodeObs.OBS_service_setVideoInfo(horizontalContext, 'horizontal');
+ NodeObs.OBS_service_setVideoInfo(verticalContext, 'vertical');
- const signalChanged = this.signalInfoChanged.subscribe((signalInfo: IOBSOutputSignalInfo) => {
- if (signalInfo.service === 'default') {
- if (signalInfo.code !== 0) {
- NodeObs.OBS_service_stopStreaming(true, 'horizontal');
- NodeObs.OBS_service_stopStreaming(true, 'vertical');
- }
+ const signalChanged = this.signalInfoChanged.subscribe(
+ (signalInfo: IOBSOutputSignalInfo) => {
+ if (signalInfo.service === 'default') {
+ 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');
- signalChanged.unsubscribe();
- }
- }
- });
+ 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);
+ }
- NodeObs.OBS_service_startStreaming('horizontal');
- // sleep for 1 second to allow the first stream to start
- await new Promise(resolve => setTimeout(resolve, 1000));
+ signalChanged.unsubscribe();
+ }
+ }
+ },
+ );
+
+ NodeObs.OBS_service_startStreaming('horizontal');
+ // sleep for 1 second to allow the first stream to start
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ }
} else {
// start single output
const horizontalContext = this.videoSettingsService.contexts.horizontal;
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;
@@ -906,7 +971,7 @@ export class StreamingService
remote.powerSaveBlocker.stop(this.powerSaveId);
}
- if (this.views.isDualOutputMode) {
+ if (this.views.isDualOutputMode && !this.dualOutputService.views.recordVertical) {
const signalChanged = this.signalInfoChanged.subscribe(
(signalInfo: IOBSOutputSignalInfo) => {
if (
@@ -914,6 +979,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();
}
},
@@ -927,7 +996,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();
}
@@ -946,8 +1018,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);
@@ -970,7 +1049,7 @@ export class StreamingService
this.toggleRecording();
}
- toggleRecording() {
+ recordWithOldAPI() {
if (this.state.recordingStatus === ERecordingState.Recording) {
NodeObs.OBS_service_stopRecording();
return;
@@ -982,6 +1061,311 @@ export class StreamingService
}
}
+ /**
+ * Part of a workaround to set recording quality on the frontend to avoid a backend bug
+ * @remark TODO: remove when migrate output and stream settings to new API
+ * Because of a bug with the new API and having the `Same as stream` setting, temporarily store and set
+ * the recording quality on the frontend
+ * @param mode - single output or dual output
+ */
+ beforeStartRecording(mode: TStreamMode) {
+ /**
+ * When going live in dual output mode, `Same as stream` is a not valid setting because of a bug in the backend in the new API.
+ * To prevent errors, if the recording quality set on the old API object is `Same as Stream`
+ * temporarily change the recording quality set on the old API object to the same as the recording quality
+ * stored in the dual output service.
+
+ * To further prevent errors, until the output and stream settings are migrated to the new API,
+ * in single output mode, use the old api
+ */
+ if (mode === 'dual') {
+ // recording quality stored on the front in the dual output service
+ const dualOutputSingleRecordingQuality = this.dualOutputService.views.recordingQuality
+ .dualOutput;
+
+ // recording quality set on the old API object
+ const singleOutputRecordingQuality = this.outputSettingsService.getRecordingQuality();
+
+ if (singleOutputRecordingQuality === ERecordingQuality.Stream) {
+ // store the old API setting in the frontend
+ this.dualOutputService.setDualOutputRecordingQuality(
+ 'single',
+ dualOutputSingleRecordingQuality,
+ );
+
+ // match the old API setting to the dual output setting
+ this.settingsService.setSettingValue(
+ 'Recording',
+ 'ReqQuality',
+ dualOutputSingleRecordingQuality,
+ );
+ }
+ } else {
+ // recording quality stored on the front in the dual output service
+ const dualOutputSingleRecordingQuality = this.dualOutputService.views.recordingQuality
+ .dualOutput;
+
+ // recording quality set on the old API object
+ const singleOutputRecordingQuality = this.outputSettingsService.getRecordingQuality();
+
+ // if needed, in single output mode restore the `Same as stream` setting on the old API
+ if (
+ singleOutputRecordingQuality !== dualOutputSingleRecordingQuality &&
+ dualOutputSingleRecordingQuality === ERecordingQuality.Stream
+ ) {
+ // when going live in single output mode, `Same as stream` is a valid setting
+ this.settingsService.setSettingValue('Recording', 'ReqQuality', ERecordingQuality.Stream);
+ }
+ }
+ }
+
+ /**
+ * Start/stop recording
+ * @remark for single output mode use the old API until output and stream settings are migrated to the new API
+ */
+ toggleRecording() {
+ const recordingMode = this.views.isDualOutputMode ? 'dual' : 'single';
+ this.beforeStartRecording(recordingMode);
+
+ if (!this.views.isDualOutputMode) {
+ // single output mode
+ this.recordWithOldAPI();
+ } else {
+ // dual output mode
+
+ // stop recording
+ if (
+ this.state.recordingStatus === ERecordingState.Recording &&
+ this.state.verticalRecordingStatus === ERecordingState.Recording
+ ) {
+ // stop recording 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.dualOutputService.views.recordVertical &&
+ 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
+ // TODO: remove after migrate output and stream settings to new api
+ this.recordWithOldAPI();
+ // 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[key] = settings[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['outputWidth'] = this.dualOutputService.views.videoSettings[display].outputWidth;
+ recording['outputHeight'] = this.dualOutputService.views.videoSettings[display].outputHeight;
+
+ const trackName = `track${index}`;
+ const track = AudioTrackFactory.create(160, trackName);
+ AudioTrackFactory.setAtIndex(track, index);
+ } else {
+ recording['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)[info.signal];
+
+ // We received a signal we didn't recognize
+ if (!nextState) {
+ console.error('Received unrecognized signal: ', 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 (nextState === ERecordingState.Wrote) {
+ const fileName = await this.getFileName(display);
+
+ 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.handleOutputCode(info);
+ return;
+ }
+
+ const time = new Date().toISOString();
+ this.SET_RECORDING_STATUS(nextState, time, display);
+ this.recordingStatusChange.next(nextState);
+
+ this.handleOutputCode(info);
+ }
+
+ /**
+ * Gets the filename of the recording
+ * @remark In the new API, there can be a delay between the wrote signal
+ * and the finalizing of the file in the directory. The existence of the file name
+ * is a signifier that it has been finalized. In order for the file name to populate
+ * in the recording history, make sure the file has been finalized
+ * @param display - determines which recording object to use
+ * @returns string file name
+ */
+ async getFileName(display: TDisplayType): Promise {
+ return await new Promise(resolve => {
+ const wroteInterval = setInterval(() => {
+ // confirm vertical recording exists
+ let filename = '';
+ if (display === 'vertical' && this.verticalRecording) {
+ filename = this.verticalRecording.lastFile();
+ } else if (display === 'horizontal' && this.horizontalRecording) {
+ filename = this.horizontalRecording.lastFile();
+ }
+
+ if (filename !== '') {
+ resolve(filename);
+ clearInterval(wroteInterval);
+ }
+ }, 300);
+ });
+ }
+
splitFile() {
if (this.state.recordingStatus === ERecordingState.Recording) {
NodeObs.OBS_service_splitFile();
@@ -1089,6 +1473,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));
}
@@ -1137,8 +1528,22 @@ export class StreamingService
private handleOBSOutputSignal(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 && info.service === 'vertical');
+ !this.views.isDualOutputMode ||
+ (this.views.isDualOutputMode && isVerticalDisplayStartSignal) ||
+ (this.views.isDualOutputMode &&
+ info.signal === EOBSOutputSignal.Start &&
+ this.dualOutputService.views.recordVertical);
const time = new Date().toISOString();
@@ -1206,6 +1611,10 @@ export class StreamingService
this.clearReconnectingNotification();
}
} else if (info.type === EOBSOutputType.Recording) {
+ // TODO: remove when migrate to output and stream settings implemented
+ // Currently, single output mode uses the old api while dual output mode uses the new api.
+ // This listens for the signals in the new API while the signal handler used in toggleRecording
+ // is for the new API
const nextState: ERecordingState = ({
[EOBSOutputSignal.Start]: ERecordingState.Recording,
[EOBSOutputSignal.Starting]: ERecordingState.Starting,
@@ -1215,7 +1624,10 @@ export class StreamingService
} as Dictionary)[info.signal];
// We received a signal we didn't recognize
- if (!nextState) return;
+ if (!nextState) {
+ console.error('Received unrecognized signal: ', nextState);
+ return;
+ }
if (info.signal === EOBSOutputSignal.Start) {
this.usageStatisticsService.recordFeatureUsage('Recording');
@@ -1263,7 +1675,10 @@ export class StreamingService
this.replayBufferFileWrite.next(NodeObs.OBS_service_getLastReplay());
}
}
+ this.handleOutputCode(info);
+ }
+ private handleOutputCode(info: IOBSOutputSignalInfo | EOutputSignal) {
if (info.code) {
if (this.outputErrorOpen) {
console.warn('Not showing error message because existing window is open.', info);
@@ -1437,10 +1852,29 @@ export class StreamingService
if (time) this.state.streamingStatusTime = time;
}
+ /**
+ * Refactor when streaming moved to the new api
+ * Currently only used in dual output mode to show streaming status of the vertical display
+ */
+ @mutation()
+ private SET_VERTICAL_STREAMING_STATUS(status: EStreamingState, time?: string) {
+ this.state.verticalStreamingStatus = status;
+ if (time) this.state.verticalStreamingStatusTime = time;
+ }
+
@mutation()
- private SET_RECORDING_STATUS(status: ERecordingState, time: string) {
- this.state.recordingStatus = status;
- this.state.recordingStatusTime = time;
+ 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()
diff --git a/test/helpers/form-monkey.ts b/test/helpers/form-monkey.ts
index 7e26cf15aa61..f357db196ed3 100644
--- a/test/helpers/form-monkey.ts
+++ b/test/helpers/form-monkey.ts
@@ -567,11 +567,18 @@ export class FormMonkey {
/**
* returns selector for the input element by a title
*/
- async getInputSelectorByTitle(inputTitle: string): Promise {
+ async getInputSelectorByTitle(inputTitle: string, isDataName: boolean = false): Promise {
const name = await this.getInputNameByTitle(inputTitle);
return `[data-role="input"][data-name="${name}"]`;
}
+ /**
+ * returns selector for the input element by a title
+ */
+ async getInputSelectorByName(name: string): Promise {
+ return `[data-name="${name}"]`;
+ }
+
/**
* returns name for the input element by a title
*/
@@ -604,7 +611,7 @@ export function selectTitle(optionTitle: string): FNValueSetter {
await form.waitForLoading(input.name);
// click on the first option
- await click( `${input.selector} .multiselect__element`);
+ await click(`${input.selector} .multiselect__element`);
};
}
diff --git a/test/helpers/modules/core.ts b/test/helpers/modules/core.ts
index 29c6791eedb2..ea45342fa9aa 100644
--- a/test/helpers/modules/core.ts
+++ b/test/helpers/modules/core.ts
@@ -68,6 +68,12 @@ export async function clickCheckbox(dataName: string) {
await $checkbox.click();
}
+// @@@ TODO WIP
+export async function clickRadio(radioText: string) {
+ const $radio = await select(`input[type="radio"]=${radioText}`);
+ await $radio.click();
+}
+
// OTHER SHORTCUTS
export async function hoverElement(selector: string, duration?: number) {
diff --git a/test/helpers/modules/streaming.ts b/test/helpers/modules/streaming.ts
index ddf1b21ca097..1267c8f6d184 100644
--- a/test/helpers/modules/streaming.ts
+++ b/test/helpers/modules/streaming.ts
@@ -7,7 +7,8 @@ import { getApiClient } from '../api-client';
import {
click,
clickButton,
- focusChild, getFocusedWindowId,
+ focusChild,
+ getFocusedWindowId,
isDisplayed,
select,
selectButton,
@@ -118,6 +119,10 @@ export async function chatIsVisible() {
export async function startRecording() {
await click('.record-button');
+ // Refactor when migrate output and stream settings to new API
+ // currently, because of the data mapping from the old api
+ // that is needed to work with the new api, it take a little longer to start the recording
+ await sleep(500);
await waitForDisplayed('.record-button.active');
}
diff --git a/test/regular/recording.ts b/test/regular/recording.ts
index 551db9d4d377..632728791a41 100644
--- a/test/regular/recording.ts
+++ b/test/regular/recording.ts
@@ -1,23 +1,103 @@
import { readdir } from 'fs-extra';
-import { test, useWebdriver } from '../helpers/webdriver';
+import { ITestContext, test, useWebdriver } from '../helpers/webdriver';
import { sleep } from '../helpers/sleep';
-import { startRecording, stopRecording } from '../helpers/modules/streaming';
+import {
+ clickGoLive,
+ prepareToGoLive,
+ startRecording,
+ stopRecording,
+} from '../helpers/modules/streaming';
import { FormMonkey } from '../helpers/form-monkey';
import {
setOutputResolution,
setTemporaryRecordingPath,
showSettingsWindow,
} from '../helpers/modules/settings/settings';
-import { clickButton, focusMain } from '../helpers/modules/core';
+import {
+ clickButton,
+ focusMain,
+ selectElements,
+ focusChild,
+ closeWindow,
+} from '../helpers/modules/core';
import { logIn } from '../helpers/webdriver/user';
+import { logIn as multiLogIn } from '../helpers/modules/user';
import { toggleDualOutputMode } from '../helpers/modules/dual-output';
+import { showPage } from '../helpers/modules/navigation';
+import { setFormDropdown } from '../helpers/webdriver/forms';
+import { ExecutionContext } from 'ava';
useWebdriver();
+async function confirmFilesCreated(
+ t: ExecutionContext,
+ tmpDir: string,
+ numCreatedFiles: number,
+) {
+ const recordings = await selectElements('span.file');
+ t.true(recordings.length === numCreatedFiles);
+
+ // check that every files was created with correct file name
+ const files = await readdir(tmpDir);
+ t.true(recordings.length === files.length);
+ const fileNames = files.map(file => file.split('/').pop());
+ recordings.forEach(async recording => {
+ const recordingName = await recording.getText();
+ t.true(fileNames.includes[recordingName]);
+ });
+}
+
+async function recordAndConfirmRecordings(
+ t: ExecutionContext,
+ tmpDir: string,
+ qualities: string[],
+ setSettings: (setting: string) => Promise,
+ numExpectedRecordings: number,
+) {
+ for (const quality of qualities) {
+ await setSettings(quality);
+ await focusMain();
+ await startRecording();
+ await sleep(500);
+ await stopRecording();
+
+ // Wait to ensure that output setting are editable
+ await sleep(500);
+ }
+
+ // Check that every file was created
+ const files = await readdir(tmpDir);
+
+ console.log('files length ', files.length);
+
+ // M3U8 creates multiple TS files in addition to the catalog itself.
+ t.true(files.length === numExpectedRecordings, `Files that were created:\n${files.join('\n')}`);
+}
+
+async function prepareRecordDualOutput(t: ExecutionContext): Promise {
+ await multiLogIn('twitch', { multistream: true });
+ await toggleDualOutputMode();
+
+ const tmpDir = await setTemporaryRecordingPath();
+ // low resolution reduces CPU usage
+ await setOutputResolution('100x100');
+
+ await showSettingsWindow('Output', async () => {
+ await setFormDropdown('Recording Quality', 'High Quality, Medium File Size');
+ await sleep(500);
+ await clickButton('Done');
+ });
+
+ // Recording mode: record horizontal and vertical displays
+ await focusMain();
+
+ return tmpDir;
+}
+
/**
- * Recording with one context active (horizontal)
+ * Records all formats in single output mode with a vanilla scene collection
+ * and dual output mode with a dual output scene collection
*/
-
test('Recording', async t => {
const tmpDir = await setTemporaryRecordingPath();
@@ -33,7 +113,6 @@ test('Recording', async t => {
await form.setInputValue(await form.getInputSelectorByTitle('Recording Format'), format);
await clickButton('Done');
});
-
await focusMain();
await startRecording();
await sleep(500);
@@ -44,23 +123,20 @@ test('Recording', async t => {
}
// Check that every file was created
- const files = await readdir(tmpDir);
+ const singleOutputFiles = await readdir(tmpDir);
// 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(
+ singleOutputFiles.length >= formats.length,
+ `Files that were created:\n${singleOutputFiles.join('\n')}`,
+ );
-/**
- * Recording with two contexts active (horizontal and vertical)
- * should produce no different results than with one context.
- */
-test('Recording with two contexts active', async t => {
+ /**
+ * Recording in Dual Output Mode
+ */
await logIn(t);
await toggleDualOutputMode();
- const tmpDir = await setTemporaryRecordingPath();
- // low resolution reduces CPU usage
- await setOutputResolution('100x100');
- const formats = ['flv', 'mp4', 'mov', 'mkv', 'ts', 'm3u8'];
+
// Record 0.5s video in every format
for (const format of formats) {
await showSettingsWindow('Output', async () => {
@@ -68,15 +144,183 @@ test('Recording with two contexts active', async t => {
await form.setInputValue(await form.getInputSelectorByTitle('Recording Format'), format);
await clickButton('Done');
});
+
await focusMain();
await startRecording();
- await sleep(1000);
+ await sleep(500);
await stopRecording();
- // Wait to ensure that output setting are editable
- await sleep(1000);
}
+
// Check that every file was created
- const files = await readdir(tmpDir);
+ const dualOutputFiles = (await readdir(tmpDir))
+ .filter(file => !singleOutputFiles.includes(file))
+ .map(file => file);
// 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(
+ dualOutputFiles.length >= formats.length,
+ `Files that were created:\n${dualOutputFiles.join('\n')}`,
+ );
+});
+
+/**
+ * Recording in dual output mode with a dual output scene collection
+ */
+test('Recording Dual Output', async t => {
+ await logIn(t);
+ await toggleDualOutputMode();
+
+ const tmpDir = await setTemporaryRecordingPath();
+ // low resolution reduces CPU usage
+ await setOutputResolution('100x100');
+
+ await showSettingsWindow('Output', async () => {
+ await setFormDropdown('Recording Quality', 'High Quality, Medium File Size');
+ await sleep(500);
+ await clickButton('Done');
+ });
+
+ // Recording mode: record horizontal and vertical displays
+ await focusMain();
+ await startRecording();
+ // Wait for recording to start
+ await sleep(500);
+
+ // Record icons show in both headers
+ // Icons are conditionally shown/hidden prevent rendering issues with the icon visibility shifting the title text
+ const streamIcons = await selectElements('i.icon-stream.visible');
+ const recordIcons = await selectElements('i.icon-record.visible');
+
+ t.true(streamIcons.length === 0);
+ t.true(recordIcons.length === 2);
+
+ await stopRecording();
+ // Wait to ensure that both video files are finalized
+ await sleep(500);
+
+ await focusMain();
+
+ // Generated two recordings
+ await showPage('Recordings');
+ await confirmFilesCreated(t, tmpDir, 2);
+});
+
+test.skip('Recording and Streaming Dual Output', async t => {
+ await clickGoLive();
+ await focusChild();
+
+ // select(`input[type="radio"]
+ const streamIcons = await selectElements('input[type=radio]');
+ console.log('streamIcons ', streamIcons.values());
+ // TODO: toggle radio button for streaming
+ // await clickRadio('Vertical');
+
+ // const tmpDir = await prepareRecordDualOutput(t);
+
+ // console.log('tmpDir ', tmpDir);
+
+ // // Start stream
+ // await prepareToGoLive();
+ // await clickGoLive();
+ // await focusChild();
+ // await clickRadio('Vertical');
+ // await goLive();
+ // // Wait for stream to start
+ // await sleep(500);
+
+ // // Start recording
+ // await startRecording();
+ // // Wait for recording to start
+ // await sleep(500);
+
+ // // Record icons show in both headers
+ // // Icons are conditionally shown/hidden prevent rendering issues with the icon visibility shifting the title text
+ // const streamIcons = await selectElements('i.icon-stream.visible');
+ // const recordIcons = await selectElements('i.icon-record.visible');
+
+ // t.true(streamIcons.length === 2);
+ // t.true(recordIcons.length === 2);
+
+ // await stopRecording();
+ // // Wait to ensure that both video files are finalized
+ // await sleep(500);
+
+ // await stopStream();
+ // await sleep(500);
+
+ // // await focusMain();
+ // // Record icons are hidden
+ // // recordIcons = await selectElements('i.icon-record.visible');
+ // // t.true(recordIcons.length === 2);
+ // // console.log('2 recordIcons ', recordIcons.length);
+
+ // // Generated two recordings
+ // await showPage('Recordings');
+ // await confirmFilesCreated(t, tmpDir, 2);
+});
+
+/**
+ * Records all file quality in single output mode with a vanilla scene collection
+ * and dual output mode with a dual output scene collection
+ *
+ * TODO: refactor for `Same as stream` as valid dual output quality when backend fix is implemented
+ */
+test('Recording File Quality', async t => {
+ const tmpDir = await setTemporaryRecordingPath();
+
+ // low resolution reduces CPU usage
+ await setOutputResolution('100x100');
+
+ const singleOutputQualities = [
+ 'High Quality, Medium File Size',
+ 'Indistinguishable Quality, Large File Size',
+ 'Lossless Quality, Tremendously Large File Size',
+ 'Same as stream',
+ ];
+
+ // `Same as stream` is currently not a valid option when recording dual output
+ const dualOutputQualities = [
+ 'High, Medium File Size',
+ 'Indistinguishable, Large File Size',
+ 'Lossless, Tremendously Large File Size',
+ ];
+
+ // single output recording quality
+ await recordAndConfirmRecordings(
+ t,
+ tmpDir,
+ singleOutputQualities,
+ async (quality: string) => {
+ await showSettingsWindow('Output', async () => {
+ await setFormDropdown('Recording Quality', quality);
+ await clickButton('Done');
+ });
+ },
+ singleOutputQualities.length,
+ );
+
+ // dual output recording quality
+ await logIn(t);
+ await toggleDualOutputMode();
+ await prepareToGoLive();
+ await focusMain();
+
+ // the single output recordings already exist in the directory
+ // so account for them in the number of expected recordings
+ const numExpectedRecordings = singleOutputQualities.length + dualOutputQualities.length * 2;
+
+ await recordAndConfirmRecordings(
+ t,
+ tmpDir,
+ dualOutputQualities,
+ async (quality: string) => {
+ await clickGoLive();
+ await focusChild();
+ const form = new FormMonkey(t);
+ await form.setInputValue('[data-name="recording-quality"]', quality);
+ await closeWindow('child');
+ },
+ numExpectedRecordings,
+ );
+
+ await toggleDualOutputMode();
});
diff --git a/test/regular/selective-recording.ts b/test/regular/selective-recording.ts
index da43e5e0c7de..e309fbb6ab75 100644
--- a/test/regular/selective-recording.ts
+++ b/test/regular/selective-recording.ts
@@ -4,8 +4,11 @@ import { addSource } from '../helpers/modules/sources';
import {
setOutputResolution,
setTemporaryRecordingPath,
+ showSettingsWindow,
} from '../helpers/modules/settings/settings';
-import { focusMain } from '../helpers/modules/core';
+import { clickButton, focusMain } from '../helpers/modules/core';
+import { sleep } from '../helpers/sleep';
+import { setFormDropdown } from '../helpers/webdriver/forms';
useWebdriver();