Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Resolution scaling #488

Merged
merged 4 commits into from
Dec 20, 2023
Merged
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
11 changes: 9 additions & 2 deletions src/app/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import type { Settings } from "@/settings/types";

// App settings, not user editable.
export const APP_SETTINGS = {
supportedRecordingFormats: ["mp4", "mkv"]
supportedRecordingFormats: ["mp4", "mkv"],
/**
* Available resolution options (presets so users don't
* have to manually input common resolution width and heights).
*/
prefilledResolutions: ["2160p", "1440p", "1080p", "720p", "480p", "360p"] as const
};

export const DEFAULT_SETTINGS = {
Expand All @@ -30,7 +35,9 @@ export const DEFAULT_SETTINGS = {
name: "Primary Monitor"
},
fps: 60,
resolution: "1080p",
resolutionScale: "disabled",
resolutionCustom: { width: 1920, height: 1080 },
resolutionKeepAspectRatio: true,
format: "mp4",
zeroLatency: true,
ultraFast: true,
Expand Down
7 changes: 4 additions & 3 deletions src/common/DropDown.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useState } from "react";
import Icon from "./Icon";
import type { CommonComponentProps } from "./types";

interface DropDownProps {
interface DropDownProps extends CommonComponentProps {
activeItem: DropDownItem | string | number;
items: DropDownItem[] | string[] | number[];
onChange: (selected: DropDownItem | string | number) => void;
Expand All @@ -20,12 +21,12 @@ export interface DropDownItem {
name: string;
}

export default function DropDown({ activeItem, items, onChange }: DropDownProps) {
export default function DropDown({ activeItem, items, onChange, className }: DropDownProps) {
const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState(activeItem);

return (
<div className="flex flex-col bg-secondary-100 rounded">
<div className={`flex flex-col bg-secondary-100 rounded ${className ?? ""}`}>
<label
className={`${
isOpen ? "rounded-t" : "rounded"
Expand Down
130 changes: 72 additions & 58 deletions src/libs/recorder/argumentBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,25 @@ export default class ArgumentBuilder {

constructor(private readonly customRegion: CustomRegion) {}

private bounds: CustomRegion;

/**
* Create FFmpeg arguments.
* Automatically builds the correct arguments depending on current OS.
*/
public async createArgs(): Promise<Arguments> {
if (this.customRegion) {
this.bounds = this.customRegion;
} else {
const monitorToRecord = ArgumentBuilder.rs.monitorToRecord.id.toLowerCase();
// Get monitor
if (monitorToRecord === "primary") {
this.bounds = (await DeviceManager.getPrimaryMonitor()).bounds;
} else {
this.bounds = (await DeviceManager.findMonitor(monitorToRecord)).bounds;
}
}

// Build and return args differently depending on OS
if (process.platform === "win32") {
return await this.buildWindowsArgs();
Expand Down Expand Up @@ -61,6 +75,10 @@ export default class ArgumentBuilder {
// Recording region
args.push(`-i ${await this.recordingRegion()}`);

if (ArgumentBuilder.rs.resolutionScale !== "disabled") {
args.push(this.recordingScale());
}

// Audio maps
args.push(`${ArgumentBuilder.audioMaps}`);

Expand Down Expand Up @@ -117,6 +135,10 @@ export default class ArgumentBuilder {
// Recording region
await this.recordingRegion();

if (ArgumentBuilder.rs.resolutionScale !== "disabled") {
args.push(this.recordingScale());
}

// Zero Latency
if (ArgumentBuilder.rs.zeroLatency) {
args.push("-tune zerolatency");
Expand Down Expand Up @@ -148,19 +170,57 @@ export default class ArgumentBuilder {
}

private async resolution(): Promise<string> {
// Initialise res and set 1920x1080 as default
const res = {
width: 1920,
height: 1080
};
if (!this.bounds) {
throw new Error("Failed to get recording WxH bounds");
}

if (this.customRegion) {
res.width = this.customRegion.width;
res.height = this.customRegion.height;
if (process.platform === "win32") {
await ArgumentBuilder.scrRegistry.add("capture_width", this.bounds.width, "REG_DWORD");
await ArgumentBuilder.scrRegistry.add("capture_height", this.bounds.height, "REG_DWORD");
}

return `${this.bounds.width}x${this.bounds.height}`;
}

private static get ffmpegDevice(): string {
if (process.platform === "win32") return "dshow";
else if (process.platform === "linux") return "x11grab";
else throw new Error("No video device to fetch for unsupported platform.");
}

private async recordingRegion(): Promise<string> {
if (!this.bounds) {
throw new Error("Failed to get recording region bounds");
}

// Return different format depending on OS
if (process.platform === "win32") {
await ArgumentBuilder.scrRegistry.add("start_x", `0x${toHexTwosComplement(this.bounds.x)}`, "REG_DWORD");
await ArgumentBuilder.scrRegistry.add("start_y", `0x${toHexTwosComplement(this.bounds.y)}`, "REG_DWORD");

// Return offsets as string anyway
return `-offset_x ${this.bounds.x} -offset_y ${this.bounds.y}`;
} else if (process.platform === "linux") {
return `:0.0+${this.bounds.x},${this.bounds.y}`;
} else {
throw new Error("Can't get recording region for unsupported platform.");
}
}

private recordingScale(): string {
const rscale = ArgumentBuilder.rs.resolutionScale;
console.debug("recordingScale running", rscale);
if (rscale === "disabled") return "";
const res = { width: 0, height: 0 };
if (rscale === "custom") {
const customScale = ArgumentBuilder.rs.resolutionCustom;
if (!customScale?.width || !customScale?.height) {
throw new Error("Resolution is set to 'custom', but custom resolution setting if not defined!");
}
res.width = customScale.width;
res.height = customScale.height;
} else {
switch (ArgumentBuilder.rs.resolution) {
case "In-Game":
throw new Error("In-Game directive not currently supported.");
switch (rscale) {
case "2160p":
res.width = 3840;
res.height = 2160;
Expand All @@ -187,53 +247,7 @@ export default class ArgumentBuilder {
break;
}
}

if (process.platform === "win32") {
await ArgumentBuilder.scrRegistry.add("capture_width", res.width, "REG_DWORD");
await ArgumentBuilder.scrRegistry.add("capture_height", res.height, "REG_DWORD");
}

return `${res.width}x${res.height}`;
}

private static get ffmpegDevice(): string {
if (process.platform === "win32") return "dshow";
else if (process.platform === "linux") return "x11grab";
else throw new Error("No video device to fetch for unsupported platform.");
}

private async recordingRegion(): Promise<string> {
let bounds;

if (this.customRegion) {
bounds = this.customRegion;
} else {
const monitorToRecord = ArgumentBuilder.rs.monitorToRecord.id.toLowerCase();

// Get monitor
if (monitorToRecord === "primary") {
bounds = (await DeviceManager.getPrimaryMonitor()).bounds;
} else {
bounds = (await DeviceManager.findMonitor(monitorToRecord)).bounds;
}
}

if (!bounds) {
throw new Error("Failed to get recording region bounds");
}

// Return different format depending on OS
if (process.platform === "win32") {
await ArgumentBuilder.scrRegistry.add("start_x", `0x${toHexTwosComplement(bounds.x)}`, "REG_DWORD");
await ArgumentBuilder.scrRegistry.add("start_y", `0x${toHexTwosComplement(bounds.y)}`, "REG_DWORD");

// Return offsets as string anyway
return `-offset_x ${bounds.x} -offset_y ${bounds.y}`;
} else if (process.platform === "linux") {
return `:0.0+${bounds.x},${bounds.y}`;
} else {
throw new Error("Can't get recording region for unsupported platform.");
}
return `-vf scale=${res.width}:${ArgumentBuilder.rs.resolutionKeepAspectRatio ? "-1" : res.height}`;
}

private static get audioMaps(): string {
Expand Down
64 changes: 53 additions & 11 deletions src/settings/pages/Recording.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@ import {
setHardwareEncoding,
setMonitorToRecord,
setResolution,
setResolutionCustom,
setResolutionKeepAspectRatio,
setSeperateAudioTracks,
setThumbSaveFolder,
setVideoDevice,
setVideoSaveFolder,
setVideoSaveName,
toggleAudioDeviceToRecord
} from "../settingsSlice";
import type { ResolutionScale } from "../types";

export default function Recording() {
const state = useSelector((store: RootState) => store.settings.recording);
Expand All @@ -31,7 +34,7 @@ export default function Recording() {

const videoDevices = ["Default"];
const monitors = new Array<DropDownItem>({ id: "primary", name: "Primary Monitor" });
const resolutions = ["In-Game", "2160p", "1440p", "1080p", "720p", "480p", "360p"];
const resolutions: ResolutionScale[] = ["disabled", "2160p", "1440p", "1080p", "720p", "480p", "360p", "custom"];

useEffect(() => {
DeviceManager.getDevices()
Expand Down Expand Up @@ -66,6 +69,14 @@ export default function Recording() {
/>
</NamedContainer>

<NamedContainer title="Format">
<DropDown
activeItem={state.format}
items={APP_SETTINGS.supportedRecordingFormats}
onChange={(s) => dispatch(setFormat(s as string))}
/>
</NamedContainer>

<NamedContainer title="FPS">
<TextBox
type="number"
Expand All @@ -77,21 +88,52 @@ export default function Recording() {
/>
</NamedContainer>

<NamedContainer title="Resolution">
<NamedContainer title="Resolution Scale">
<DropDown
activeItem={state.resolution}
activeItem={state.resolutionScale}
items={resolutions}
onChange={(s) => dispatch(setResolution(s as string))}
onChange={(s) => dispatch(setResolution(s as ResolutionScale))}
className="capitalize"
/>
</NamedContainer>

<NamedContainer title="Format">
<DropDown
activeItem={state.format}
items={APP_SETTINGS.supportedRecordingFormats}
onChange={(s) => dispatch(setFormat(s as string))}
/>
</NamedContainer>
{state.resolutionScale === "custom" && (
<NamedContainer title="Custom Resolution">
<div className="flex row gap-3 items-center">
<b>W</b>
<TextBox
type="number"
value={state.resolutionCustom?.width ?? ""}
placeholder={1920}
onChange={(s) => {
dispatch(setResolutionCustom({ width: s }));
}}
/>
<b>H</b>
<TextBox
type="number"
value={state.resolutionCustom?.height ?? ""}
placeholder={1080}
onChange={(s) => {
dispatch(setResolutionCustom({ height: s }));
}}
/>
</div>
</NamedContainer>
)}

{state.resolutionScale !== "disabled" && (
<NamedContainer
title="Keep Aspect Ratio"
desc="When using the resolution scaling option, you can decide if you want to keep the original aspect ratio or not."
row
>
<TickBox
ticked={state.resolutionKeepAspectRatio}
onChange={(t) => dispatch(setResolutionKeepAspectRatio(t))}
/>
</NamedContainer>
)}

{process.platform === "linux" && (
<NamedContainer
Expand Down
15 changes: 12 additions & 3 deletions src/settings/settingsSlice.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { DEFAULT_SETTINGS } from "@/app/constants";
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
import { type MonitorToRecord } from "./types";
import type { ResolutionScale, MonitorToRecord } from "./types";

const settingsSlice = createSlice({
name: "settings",
Expand Down Expand Up @@ -46,8 +46,15 @@ const settingsSlice = createSlice({
setFps(state, action: PayloadAction<number>) {
state.recording.fps = action.payload;
},
setResolution(state, action: PayloadAction<string>) {
state.recording.resolution = action.payload;
setResolution(state, action: PayloadAction<ResolutionScale>) {
state.recording.resolutionScale = action.payload;
},
setResolutionCustom(state, action: PayloadAction<{ width?: number; height?: number }>) {
if (action.payload.width) state.recording.resolutionCustom.width = action.payload.width;
if (action.payload.height) state.recording.resolutionCustom.height = action.payload.height;
},
setResolutionKeepAspectRatio(state, action: PayloadAction<boolean>) {
state.recording.resolutionKeepAspectRatio = action.payload;
},
setFormat(state, action: PayloadAction<string>) {
state.recording.format = action.payload;
Expand Down Expand Up @@ -103,6 +110,8 @@ export const {
setMonitorToRecord,
setFps,
setResolution,
setResolutionCustom,
setResolutionKeepAspectRatio,
setFormat,
setZeroLatency,
setUltraFast,
Expand Down
14 changes: 13 additions & 1 deletion src/settings/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import type { APP_SETTINGS } from "@/app/constants";

export type ResolutionScale = typeof APP_SETTINGS.prefilledResolutions[number] | "disabled" | "custom";

export interface Settings {
general: GeneralSettings;
recording: RecordingSettings;
Expand Down Expand Up @@ -41,7 +45,15 @@ export interface RecordingSettings {
videoDevice: string;
monitorToRecord: MonitorToRecord;
fps: number;
resolution: string;
/**
* When set, video output will be scaled to this resolution.
*/
resolutionScale: ResolutionScale;
resolutionCustom: { width: number; height: number };
/**
* If a resolution is set to scale to, should we keep the aspect ratio of original video?
*/
resolutionKeepAspectRatio: boolean;
format: string;
zeroLatency: boolean;
ultraFast: boolean;
Expand Down
Loading