Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/app-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export { SideNavService } from 'services/side-nav';
export { VideoSettingsService } from 'services/settings-v2/video';
export { SettingsManagerService } from 'services/settings-manager';
export { MarkersService } from 'services/markers';
export { ScreenshotService } from 'services/screenshot';
export { RealmService } from 'services/realm';
export { StreamAvatarService } from 'services/stream-avatar/stream-avatar-service';
export { StreamAvatarApiService } from 'services/stream-avatar/stream-avatar-api-service';
Expand Down Expand Up @@ -197,6 +198,7 @@ import { VideoSettingsService } from 'services/settings-v2/video';
import { SettingsManagerService } from 'services/settings-manager';
import { DualOutputService } from 'services/dual-output';
import { MarkersService } from 'services/markers';
import { ScreenshotService } from 'services/screenshot';
import { SharedStorageService } from 'services/integrations/shared-storage';
import { RealmService } from 'services/realm';
import { InstagramService } from 'services/platforms/instagram';
Expand Down Expand Up @@ -295,6 +297,7 @@ export const AppServices = {
SettingsManagerService,
DualOutputService,
MarkersService,
ScreenshotService,
SharedStorageService,
RealmService,
RemoteControlService,
Expand Down
5 changes: 5 additions & 0 deletions app/i18n/en-US/hotkeys.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
{
"Start Streaming": "Start Streaming",
"Stop Streaming": "Stop Streaming",
"Screenshot Output": "Screenshot Output",
"Screenshot saved as %{filename}": "Screenshot saved as %{filename}",
"Could not save screenshot": "Could not save screenshot",
"Screenshots need a newer version of the Streamlabs backend.": "Screenshots need a newer version of the Streamlabs backend.",
"Set a recording folder in Settings > Output before taking a screenshot.": "Set a recording folder in Settings > Output before taking a screenshot.",
"Start Recording": "Start Recording",
"Stop Recording": "Stop Recording",
"Switch to scene": "Switch to scene",
Expand Down
10 changes: 10 additions & 0 deletions app/services/hotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SourcesService, TSourceType } from 'services/sources';
import { TransitionsService } from 'services/transitions';
import { KeyListenerService } from 'services/key-listener';
import { MarkersService } from 'services/markers';
import { ScreenshotService } from 'services/screenshot';
import { Inject } from 'services/core/injector';
import { StatefulService, mutation, ServiceHelper } from 'services';
import defer from 'lodash/defer';
Expand Down Expand Up @@ -54,6 +55,10 @@ function getMarkersService(): MarkersService {
return MarkersService.instance;
}

function getScreenshotService(): ScreenshotService {
return ScreenshotService.instance;
}

const isAudio = (sourceId: string) => {
const source = getSourcesService().views.getSource(sourceId);

Expand Down Expand Up @@ -177,6 +182,11 @@ const GENERAL_ACTIONS: HotkeyGroup = {
description: () => $t('Split Recording File'),
down: () => getStreamingService().splitFile(),
},
SCREENSHOT_OUTPUT: {
name: 'SCREENSHOT_OUTPUT',
description: () => $t('Screenshot Output'),
down: () => getScreenshotService().takeScreenshot(),
},
TOGGLE_OVERLAY: {
name: 'TOGGLE_OVERLAY',
description: () => $t('Toggle in-game overlay'),
Expand Down
89 changes: 89 additions & 0 deletions app/services/screenshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import fs from 'fs';
import path from 'path';
import * as remote from '@electron/remote';
import { Inject, Service } from 'services/core';
import * as obs from '../../obs-api';
import { $t } from './i18n';
import { SettingsService } from './settings';
import { OutputSettingsService } from './settings/output';
import { TDisplayType, VideoSettingsService } from './settings-v2/video';
import { ENotificationType, NotificationsService } from './notifications';
import { JsonrpcService } from './api/jsonrpc';
import { UsageStatisticsService } from './usage-statistics';

/** OBS's default "Filename Formatting", used when the setting is empty. */
const DEFAULT_FILENAME_FORMAT = '%CCYY-%MM-%DD %hh-%mm-%ss';

/**
* Saves a still of the program output, the way OBS Studio's "Screenshot Output"
* hotkey does: a PNG at canvas resolution, written to the recording folder as
* `Screenshot <Filename Formatting>.png`. The capture itself happens in the OBS
* backend (`OBS_content_takeScreenshot`); this service supplies the settings and
* reports the result.
*/
export class ScreenshotService extends Service {
@Inject() private settingsService: SettingsService;
@Inject() private outputSettingsService: OutputSettingsService;
@Inject() private videoSettingsService: VideoSettingsService;
@Inject() private notificationsService: NotificationsService;
@Inject() private jsonrpcService: JsonrpcService;
@Inject() private usageStatisticsService: UsageStatisticsService;

async takeScreenshot(display: TDisplayType = 'horizontal') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed; deferred until the osn release with OBS_content_takeScreenshot is pinned. Before that, a test can only reach the needs-newer-backend path.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (typeof obs.NodeObs.OBS_content_takeScreenshot !== 'function') {
this.warn($t('Screenshots need a newer version of the Streamlabs backend.'));
return;
}

const context = this.videoSettingsService.contexts[display];
if (!context) {
this.warn($t('Could not save screenshot'));
return;
}

const directory = this.outputSettingsService.getRecordingSettings(display).path;
if (!directory || !fs.existsSync(directory)) {
this.warn($t('Set a recording folder in Settings > Output before taking a screenshot.'));
return;
}

const output = this.settingsService.state.Output.formData;
const advanced = this.settingsService.state.Advanced.formData;
const mode = this.settingsService.findSettingValue(output, 'Untitled', 'Mode');
const noSpaceKey = mode === 'Advanced' ? 'RecFileNameWithoutSpace' : 'FileNameWithoutSpace';
const noSpace = !!this.settingsService.findSettingValue(output, 'Recording', noSpaceKey);
const format: string =
this.settingsService.findSettingValue(advanced, 'Recording', 'FilenameFormatting') ||
DEFAULT_FILENAME_FORMAT;

try {
const result = await obs.NodeObs.OBS_content_takeScreenshot(
context,
directory,
format,
noSpace,
);
this.notificationsService.push({
type: ENotificationType.SUCCESS,
message: $t('Screenshot saved as %{filename}', { filename: path.basename(result.path) }),
action: this.jsonrpcService.createRequest(
Service.getResourceId(this),
'showScreenshot',
result.path,
),
});
this.usageStatisticsService.recordFeatureUsage('ScreenshotOutput');
} catch (e: unknown) {
console.error('Failed to take screenshot', e);
this.warn($t('Could not save screenshot'));
}
}

showScreenshot(filePath: string) {
remote.shell.showItemInFolder(filePath);
}

private warn(message: string) {
this.notificationsService.push({ type: ENotificationType.WARNING, message });
}
}
38 changes: 38 additions & 0 deletions test/regular/screenshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { readdir, readFile } from 'fs-extra';
import * as path from 'path';
import { test, useWebdriver } from '../helpers/webdriver';
import { getApiClient } from '../helpers/api-client';
import { setTemporaryRecordingPath } from '../helpers/modules/settings/settings';
import { focusMain, waitForDisplayed } from '../helpers/modules/core';
import { ScreenshotService } from 'app-services';

// not a react hook
// eslint-disable-next-line react-hooks/rules-of-hooks
useWebdriver();

const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);

test('Screenshot Output saves a PNG of the program output to the recording folder', async t => {
const recordingDir = await setTemporaryRecordingPath();
await focusMain();

const client = await getApiClient();
const screenshotService = client.getResource<ScreenshotService>('ScreenshotService');

await screenshotService.takeScreenshot();
Comment thread
summeroff marked this conversation as resolved.

const files = (await readdir(recordingDir)).filter(file => /^Screenshot .+\.png$/.test(file));
t.is(files.length, 1, 'exactly one screenshot was written to the recording folder');

// The success toast in the footer is the only place the message is readable: the service
// filters notifications out of its API state, and the notifications window lists only
// performance notifications.
// Matched without the extension: webdriverio reads a selector ending in an image extension
// as a file path.
const savedAs = files[0].replace(/\.png$/, '');
await waitForDisplayed(`div*=Screenshot saved as ${savedAs}`, { timeout: 5000 });

const bytes = await readFile(path.join(recordingDir, files[0]));
t.true(bytes.length > PNG_SIGNATURE.length, 'the screenshot has a body');
t.deepEqual(bytes.subarray(0, PNG_SIGNATURE.length), PNG_SIGNATURE, 'the screenshot is a PNG');
});
1 change: 1 addition & 0 deletions test/regular/settings/hotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ test('Populates essential hotkeys for them to be bound', async t => {
'Disable Studio Mode',
'Transition (Studio Mode)',
'Save Replay',
'Screenshot Output',
'Mute',
'Unmute',
'Push to Mute',
Expand Down
Loading