Explorar o código

UI: Add OBS_PROPERTY_FRAME_RATE implementation

Palana %!s(int64=10) %!d(string=hai) anos
pai
achega
ddfe6483b3

+ 1 - 0
obs/CMakeLists.txt

@@ -155,6 +155,7 @@ set(obs_HEADERS
 	window-projector.hpp
 	window-remux.hpp
 	properties-view.hpp
+	properties-view.moc.hpp
 	display-helpers.hpp
 	double-slider.hpp
 	focus-list.hpp

+ 5 - 0
obs/data/locale/en-US.ini

@@ -173,6 +173,11 @@ Basic.PropertiesWindow.AddEditableListFiles="Add files to '%1'"
 Basic.PropertiesWindow.AddEditableListEntry="Add entry to '%1'"
 Basic.PropertiesWindow.EditEditableListEntry="Edit entry from '%1'"
 
+# properties view
+Basic.PropertiesView.FPS.Simple="Simple FPS Values"
+Basic.PropertiesView.FPS.Rational="Rational FPS Values"
+Basic.PropertiesView.FPS.ValidFPSRanges="Valid FPS Ranges:"
+
 # interaction window
 Basic.InteractionWindow="Interacting with '%1'"
 

+ 736 - 0
obs/properties-view.cpp

@@ -17,10 +17,15 @@
 #include <QPlainTextEdit>
 #include <QDialogButtonBox>
 #include <QMenu>
+#include <QStackedWidget>
 #include "double-slider.hpp"
 #include "qt-wrappers.hpp"
 #include "properties-view.hpp"
+#include "properties-view.moc.hpp"
 #include "obs-app.hpp"
+
+#include <cstdlib>
+#include <initializer_list>
 #include <string>
 
 using namespace std;
@@ -46,6 +51,41 @@ static inline long long color_to_int(QColor color)
 		shift(color.alpha(), 24);
 }
 
+namespace {
+
+struct frame_rate_tag {
+	enum tag_type {
+		SIMPLE,
+		RATIONAL,
+		USER,
+	} type = SIMPLE;
+	const char *val = nullptr;
+
+	frame_rate_tag() = default;
+
+	explicit frame_rate_tag(tag_type type)
+		: type(type)
+	{}
+
+	explicit frame_rate_tag(const char *val)
+		: type(USER),
+		  val(val)
+	{}
+
+	static frame_rate_tag simple()   { return frame_rate_tag{SIMPLE}; }
+	static frame_rate_tag rational() { return frame_rate_tag{RATIONAL}; }
+};
+
+struct common_frame_rate {
+	const char *fps_name;
+	media_frames_per_second fps;
+};
+
+}
+
+Q_DECLARE_METATYPE(frame_rate_tag);
+Q_DECLARE_METATYPE(media_frames_per_second);
+
 void OBSPropertiesView::ReloadProperties()
 {
 	if (obj) {
@@ -634,6 +674,589 @@ void OBSPropertiesView::AddFont(obs_property_t *prop, QFormLayout *layout,
 	obs_data_release(font_obj);
 }
 
+namespace std {
+
+template <>
+struct default_delete<obs_data_t> {
+	void operator()(obs_data_t *data)
+	{
+		obs_data_release(data);
+	}
+};
+
+template <>
+struct default_delete<obs_data_item_t> {
+	void operator()(obs_data_item_t *item)
+	{
+		obs_data_item_release(&item);
+	}
+};
+
+}
+
+template <typename T>
+static double make_epsilon(T val)
+{
+	return val * 0.00001;
+}
+
+static bool matches_range(media_frames_per_second &match,
+		media_frames_per_second fps,
+		const frame_rate_range_t &pair)
+{
+	auto val  = media_frames_per_second_to_frame_interval(fps);
+	auto max_ = media_frames_per_second_to_frame_interval(pair.first);
+	auto min_ = media_frames_per_second_to_frame_interval(pair.second);
+
+	if (min_ <= val && val <= max_) {
+		match = fps;
+		return true;
+	}
+
+	return false;
+}
+
+static bool matches_ranges(media_frames_per_second &best_match,
+		media_frames_per_second fps,
+		const frame_rate_ranges_t &fps_ranges, bool exact=false)
+{
+	auto convert_fn = media_frames_per_second_to_frame_interval;
+	auto val = convert_fn(fps);
+	auto epsilon = make_epsilon(val);
+
+	bool match = false;
+	auto best_dist = numeric_limits<double>::max();
+	for (auto &pair : fps_ranges) {
+		auto max_ = convert_fn(pair.first);
+		auto min_ = convert_fn(pair.second);
+		/*blog(LOG_INFO, "%lg ≤ %lg ≤ %lg? %s %s %s",
+				min_, val, max_,
+				fabsl(min_ - val) < epsilon ? "true" : "false",
+				min_ <= val && val <= max_  ? "true" : "false",
+				fabsl(min_ - val) < epsilon ? "true" :
+				"false");*/
+
+		if (matches_range(best_match, fps, pair))
+			return true;
+
+		if (exact)
+			continue;
+
+		auto min_dist = fabsl(min_ - val);
+		auto max_dist = fabsl(max_ - val);
+		if (min_dist < epsilon && min_dist < best_dist) {
+			best_match = pair.first;
+			match = true;
+			continue;
+		}
+
+		if (max_dist < epsilon && max_dist < best_dist) {
+			best_match = pair.second;
+			match = true;
+			continue;
+		}
+
+	}
+
+	return match;
+}
+
+static media_frames_per_second make_fps(uint32_t num, uint32_t den)
+{
+	media_frames_per_second fps{};
+	fps.numerator = num;
+	fps.denominator = den;
+	return fps;
+}
+
+static const common_frame_rate common_fps[] = {
+	{"60", {60, 1}},
+	{"59.94", {60000, 1001}},
+	{"50", {50, 1}},
+	{"48", {48, 1}},
+	{"30", {30, 1}},
+	{"29.97", {30000, 1001}},
+	{"25", {25, 1}},
+	{"24", {24, 1}},
+	{"23.976", {24000, 1001}},
+};
+
+static void UpdateSimpleFPSSelection(OBSFrameRatePropertyWidget *fpsProps,
+		const media_frames_per_second *current_fps)
+{
+	if (!current_fps || !media_frames_per_second_is_valid(*current_fps)) {
+		fpsProps->simpleFPS->setCurrentIndex(0);
+		return;
+	}
+
+	auto combo = fpsProps->simpleFPS;
+	auto num = combo->count();
+	for (int i = 0; i < num; i++) {
+		auto variant = combo->itemData(i);
+		if (!variant.canConvert<media_frames_per_second>())
+			continue;
+
+		auto fps = variant.value<media_frames_per_second>();
+		if (fps != *current_fps)
+			continue;
+
+		combo->setCurrentIndex(i);
+		return;
+	}
+
+	combo->setCurrentIndex(0);
+}
+
+static void AddFPSRanges(vector<common_frame_rate> &items,
+		const frame_rate_ranges_t &ranges)
+{
+	auto InsertFPS = [&](media_frames_per_second fps)
+	{
+		auto fps_val = media_frames_per_second_to_fps(fps);
+
+		auto end_ = end(items);
+		auto i    = begin(items);
+		for (; i != end_; i++) {
+			auto i_fps_val = media_frames_per_second_to_fps(i->fps);
+			if (fabsl(i_fps_val - fps_val) < 0.01)
+				return;
+
+			if (i_fps_val > fps_val)
+				continue;
+
+			break;
+		}
+
+		items.insert(i, {nullptr, fps});
+	};
+
+	for (auto &range : ranges) {
+		InsertFPS(range.first);
+		InsertFPS(range.second);
+	}
+}
+
+static QWidget *CreateSimpleFPSValues(OBSFrameRatePropertyWidget *fpsProps,
+		bool &selected, const media_frames_per_second *current_fps)
+{
+	auto widget = new QWidget{};
+	widget->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
+
+	auto layout = new QVBoxLayout{};
+	layout->setContentsMargins(0, 0, 0, 0);
+
+	auto items = vector<common_frame_rate>{};
+	items.reserve(sizeof(common_fps)/sizeof(common_frame_rate));
+
+	auto combo = fpsProps->simpleFPS = new QComboBox{};
+
+	combo->addItem("", QVariant::fromValue(make_fps(0, 0)));
+	for (const auto &fps : common_fps) {
+		media_frames_per_second best_match{};
+		if (!matches_ranges(best_match, fps.fps, fpsProps->fps_ranges))
+			continue;
+
+		items.push_back({fps.fps_name, best_match});
+	}
+
+	AddFPSRanges(items, fpsProps->fps_ranges);
+
+	for (const auto &item : items) {
+		auto var = QVariant::fromValue(item.fps);
+		auto name = item.fps_name ?
+			QString(item.fps_name) :
+			QString("%1")
+				.arg(media_frames_per_second_to_fps(item.fps));
+		combo->addItem(name, var);
+
+		bool select = current_fps && *current_fps == item.fps;
+		if (select) {
+			combo->setCurrentIndex(combo->count() - 1);
+			selected = true;
+		}
+	}
+
+	layout->addWidget(combo, 0, Qt::AlignTop);
+	widget->setLayout(layout);
+
+	return widget;
+}
+
+static void UpdateRationalFPSWidgets(OBSFrameRatePropertyWidget *fpsProps,
+		const media_frames_per_second *current_fps)
+{
+	if (!current_fps || !media_frames_per_second_is_valid(*current_fps)) {
+		fpsProps->numEdit->setValue(0);
+		fpsProps->denEdit->setValue(0);
+		return;
+	}
+
+	auto combo = fpsProps->fpsRange;
+	auto num = combo->count();
+	for (int i = 0; i < num; i++) {
+		auto variant = combo->itemData(i);
+		if (!variant.canConvert<size_t>())
+			continue;
+
+		auto idx = variant.value<size_t>();
+		if (fpsProps->fps_ranges.size() < idx)
+			continue;
+
+		media_frames_per_second match{};
+		if (!matches_range(match, *current_fps,
+					fpsProps->fps_ranges[idx]))
+			continue;
+
+		combo->setCurrentIndex(i);
+		break;
+	}
+
+	fpsProps->numEdit->setValue(current_fps->numerator);
+	fpsProps->denEdit->setValue(current_fps->denominator);
+}
+
+static QWidget *CreateRationalFPS(OBSFrameRatePropertyWidget *fpsProps,
+		bool &selected, const media_frames_per_second *current_fps)
+{
+	auto widget = new QWidget{};
+	widget->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
+
+	auto layout = new QFormLayout{};
+	layout->setContentsMargins(0, 0, 0, 0);
+	layout->setSpacing(4);
+
+	auto str = QTStr("Basic.PropertiesView.FPS.ValidFPSRanges");
+	auto rlabel = new QLabel{str};
+
+	auto combo = fpsProps->fpsRange = new QComboBox{};
+	auto convert_fps = media_frames_per_second_to_fps;
+	//auto convert_fi  = media_frames_per_second_to_frame_interval;
+
+	for (size_t i = 0; i <  fpsProps->fps_ranges.size(); i++) {
+		auto &pair = fpsProps->fps_ranges[i];
+		combo->addItem(QString{"%1 - %2"}
+				.arg(convert_fps(pair.first))
+				.arg(convert_fps(pair.second)),
+				QVariant::fromValue(i));
+
+		media_frames_per_second match;
+		if (!current_fps || !matches_range(match, *current_fps, pair))
+			continue;
+
+		combo->setCurrentIndex(combo->count() - 1);
+		selected = true;
+	}
+
+	layout->addRow(rlabel, combo);
+
+	auto num_edit = fpsProps->numEdit = new QSpinBox{};
+	auto den_edit = fpsProps->denEdit = new QSpinBox{};
+
+	num_edit->setRange(0, INT_MAX);
+	den_edit->setRange(0, INT_MAX);
+
+	if (current_fps) {
+		num_edit->setValue(current_fps->numerator);
+		den_edit->setValue(current_fps->denominator);
+	}
+
+	layout->addRow(QTStr("Basic.Settings.Video.Numerator"), num_edit);
+	layout->addRow(QTStr("Basic.Settings.Video.Denominator"), den_edit);
+
+	widget->setLayout(layout);
+
+	return widget;
+}
+
+static OBSFrameRatePropertyWidget *CreateFrameRateWidget(obs_property_t *prop,
+		bool &warning, const char *option,
+		media_frames_per_second *current_fps,
+		frame_rate_ranges_t &fps_ranges)
+{
+	auto widget  = new OBSFrameRatePropertyWidget{};
+	auto hlayout = new QHBoxLayout{};
+	hlayout->setContentsMargins(0, 0, 0, 0);
+
+	swap(widget->fps_ranges, fps_ranges);
+
+	auto combo = widget->modeSelect = new QComboBox{};
+	combo->addItem(QTStr("Basic.PropertiesView.FPS.Simple"),
+			QVariant::fromValue(frame_rate_tag::simple()));
+	combo->addItem(QTStr("Basic.PropertiesView.FPS.Rational"),
+			QVariant::fromValue(frame_rate_tag::rational()));
+
+	auto num = obs_property_frame_rate_options_count(prop);
+	if (num)
+		combo->insertSeparator(combo->count());
+
+	bool option_found = false;
+	for (size_t i = 0; i < num; i++) {
+		auto name = obs_property_frame_rate_option_name(prop, i);
+		auto desc = obs_property_frame_rate_option_description(prop, i);
+		combo->addItem(desc, QVariant::fromValue(frame_rate_tag{name}));
+
+		if (!name || !option || string(name) != option)
+			continue;
+
+		option_found = true;
+		combo->setCurrentIndex(combo->count() - 1);
+	}
+
+	hlayout->addWidget(combo, 0, Qt::AlignTop);
+
+	auto stack = widget->modeDisplay = new QStackedWidget{};
+
+	bool match_found = option_found;
+	auto AddWidget = [&](decltype(CreateRationalFPS) func)
+	{
+		bool selected = false;
+		stack->addWidget(func(widget, selected, current_fps));
+
+		if (match_found || !selected)
+			return;
+
+		match_found = true;
+
+		stack->setCurrentIndex(stack->count() - 1);
+		combo->setCurrentIndex(stack->count() - 1);
+	};
+
+	AddWidget(CreateSimpleFPSValues);
+	AddWidget(CreateRationalFPS);
+	stack->addWidget(new QWidget{});
+
+	if (option_found)
+		stack->setCurrentIndex(stack->count() - 1);
+	else if (!match_found) {
+		int idx = current_fps ? 1 : 0; // Rational for "unsupported"
+		                               // Simple as default
+		stack->setCurrentIndex(idx);
+		combo->setCurrentIndex(idx);
+		warning = true;
+	}
+
+	hlayout->addWidget(stack, 0, Qt::AlignTop);
+
+	auto label_area = widget->labels = new QWidget{};
+	label_area->setSizePolicy(QSizePolicy::Expanding,
+			QSizePolicy::Expanding);
+
+	auto vlayout = new QVBoxLayout{};
+	vlayout->setContentsMargins(0, 0, 0, 0);
+
+	auto fps_label = widget->currentFPS = new QLabel{"FPS: 22"};
+	auto time_label = widget->timePerFrame =
+		new QLabel{"Frame Interval: 0.123 ms"};
+	auto min_label = widget->minLabel = new QLabel{"Min FPS: 1/1"};
+	auto max_label = widget->maxLabel = new QLabel{"Max FPS: 2/1"};
+
+	min_label->setHidden(true);
+	max_label->setHidden(true);
+
+	auto flags = Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard;
+	min_label->setTextInteractionFlags(flags);
+	max_label->setTextInteractionFlags(flags);
+
+	vlayout->addWidget(fps_label);
+	vlayout->addWidget(time_label);
+	vlayout->addWidget(min_label);
+	vlayout->addWidget(max_label);
+	label_area->setLayout(vlayout);
+
+	hlayout->addWidget(label_area, 0, Qt::AlignTop);
+
+	widget->setLayout(hlayout);
+
+	return widget;
+}
+
+static void UpdateMinMaxLabels(OBSFrameRatePropertyWidget *w)
+{
+	auto Hide = [&](bool hide)
+	{
+		w->minLabel->setHidden(hide);
+		w->maxLabel->setHidden(hide);
+	};
+
+	auto variant = w->modeSelect->currentData();
+	if (!variant.canConvert<frame_rate_tag>() ||
+			variant.value<frame_rate_tag>().type !=
+			frame_rate_tag::RATIONAL) {
+		Hide(true);
+		return;
+	}
+
+	variant = w->fpsRange->currentData();
+	if (!variant.canConvert<size_t>()) {
+		Hide(true);
+		return;
+	}
+
+	auto idx = variant.value<size_t>();
+	if (idx >= w->fps_ranges.size()) {
+		Hide(true);
+		return;
+	}
+
+	Hide(false);
+
+	auto min = w->fps_ranges[idx].first;
+	auto max = w->fps_ranges[idx].second;
+
+	w->minLabel->setText(QString("Min FPS: %1/%2")
+			.arg(min.numerator)
+			.arg(min.denominator));
+	w->maxLabel->setText(QString("Max FPS: %1/%2")
+			.arg(max.numerator)
+			.arg(max.denominator));
+}
+
+static void UpdateFPSLabels(OBSFrameRatePropertyWidget *w)
+{
+	UpdateMinMaxLabels(w);
+
+	unique_ptr<obs_data_item_t> obj{
+		obs_data_item_byname(w->settings, w->name)};
+
+	media_frames_per_second fps{};
+	media_frames_per_second *valid_fps = nullptr;
+	if (obs_data_item_get_autoselect_frames_per_second(obj.get(), &fps,
+				nullptr) ||
+			obs_data_item_get_frames_per_second(obj.get(), &fps,
+				nullptr))
+		valid_fps = &fps;
+
+	const char *option = nullptr;
+	obs_data_item_get_frames_per_second(obj.get(), nullptr, &option);
+
+	if (!valid_fps) {
+		w->currentFPS->setHidden(true);
+		w->timePerFrame->setHidden(true);
+		if (!option)
+			w->warningLabel->setStyleSheet(
+					"QLabel { color: red; }");
+
+		return;
+	}
+
+	w->currentFPS->setHidden(false);
+	w->timePerFrame->setHidden(false);
+
+	media_frames_per_second match{};
+	if (!option && !matches_ranges(match, *valid_fps, w->fps_ranges, true))
+		w->warningLabel->setStyleSheet("QLabel { color: red; }");
+	else
+		w->warningLabel->setStyleSheet("");
+
+	auto convert_to_fps = media_frames_per_second_to_fps;
+	auto convert_to_frame_interval =
+		media_frames_per_second_to_frame_interval;
+
+	w->currentFPS->setText(QString("FPS: %1")
+			.arg(convert_to_fps(*valid_fps)));
+	w->timePerFrame->setText(QString("Frame Interval: %1 ms")
+			.arg(convert_to_frame_interval(*valid_fps) * 1000));
+}
+
+void OBSPropertiesView::AddFrameRate(obs_property_t *prop, bool &warning,
+		QFormLayout *layout, QLabel *&label)
+{
+	const char *name = obs_property_name(prop);
+	bool enabled = obs_property_enabled(prop);
+	unique_ptr<obs_data_item_t> obj{obs_data_item_byname(settings, name)};
+
+	const char *option = nullptr;
+	obs_data_item_get_frames_per_second(obj.get(), nullptr, &option);
+
+	media_frames_per_second fps{};
+	media_frames_per_second *valid_fps = nullptr;
+	if (obs_data_item_get_frames_per_second(obj.get(), &fps, nullptr))
+		valid_fps = &fps;
+
+	frame_rate_ranges_t fps_ranges;
+	size_t num = obs_property_frame_rate_fps_ranges_count(prop);
+	fps_ranges.reserve(num);
+	for (size_t i = 0; i < num; i++)
+		fps_ranges.emplace_back(
+				obs_property_frame_rate_fps_range_min(prop, i),
+				obs_property_frame_rate_fps_range_max(prop, i));
+
+	auto widget = CreateFrameRateWidget(prop, warning, option, valid_fps,
+			fps_ranges);
+	auto info   = new WidgetInfo(this, prop, widget);
+
+	widget->name = name;
+	widget->settings = settings;
+
+	widget->modeSelect->setEnabled(enabled);
+	widget->simpleFPS->setEnabled(enabled);
+	widget->fpsRange->setEnabled(enabled);
+	widget->numEdit->setEnabled(enabled);
+	widget->denEdit->setEnabled(enabled);
+
+	label = widget->warningLabel =
+		new QLabel{obs_property_description(prop)};
+
+	layout->addRow(label, widget);
+
+	children.emplace_back(info);
+
+	UpdateFPSLabels(widget);
+
+	auto stack = widget->modeDisplay;
+	auto combo = widget->modeSelect;
+
+	auto comboIndexChanged = static_cast<void (QComboBox::*)(int)>(
+			&QComboBox::currentIndexChanged);
+	connect(combo, comboIndexChanged, stack,
+			[=](int index)
+	{
+		bool out_of_bounds = index >= stack->count();
+		auto idx = out_of_bounds ? stack->count() - 1 : index;
+		stack->setCurrentIndex(idx);
+
+		if (widget->updating)
+			return;
+
+		UpdateFPSLabels(widget);
+		emit info->ControlChanged();
+	});
+
+	connect(widget->simpleFPS, comboIndexChanged, [=](int)
+	{
+		if (widget->updating)
+			return;
+
+		emit info->ControlChanged();
+	});
+
+	connect(widget->fpsRange, comboIndexChanged, [=](int)
+	{
+		if (widget->updating)
+			return;
+
+		UpdateFPSLabels(widget);
+	});
+
+	auto sbValueChanged = static_cast<void (QSpinBox::*)(int)>(
+			&QSpinBox::valueChanged);
+	connect(widget->numEdit, sbValueChanged, [=](int)
+	{
+		if (widget->updating)
+			return;
+
+		emit info->ControlChanged();
+	});
+
+	connect(widget->denEdit, sbValueChanged, [=](int)
+	{
+		if (widget->updating)
+			return;
+
+		emit info->ControlChanged();
+	});
+}
+
 void OBSPropertiesView::AddProperty(obs_property_t *property,
 		QFormLayout *layout)
 {
@@ -680,6 +1303,9 @@ void OBSPropertiesView::AddProperty(obs_property_t *property,
 	case OBS_PROPERTY_EDITABLE_LIST:
 		AddEditableList(property, layout, label);
 		break;
+	case OBS_PROPERTY_FRAME_RATE:
+		AddFrameRate(property, warning, layout, label);
+		break;
 	}
 
 	if (widget && !obs_property_enabled(property))
@@ -713,6 +1339,112 @@ void OBSPropertiesView::SignalChanged()
 	emit Changed();
 }
 
+static bool FrameRateChangedVariant(const QVariant &variant,
+		media_frames_per_second &fps, obs_data_item_t *&obj,
+		const media_frames_per_second *valid_fps)
+{
+	if (!variant.canConvert<media_frames_per_second>())
+		return false;
+
+	fps = variant.value<media_frames_per_second>();
+	if (valid_fps && fps == *valid_fps)
+		return false;
+
+	obs_data_item_set_frames_per_second(&obj, fps, nullptr);
+	return true;
+}
+
+static bool FrameRateChangedCommon(OBSFrameRatePropertyWidget *w,
+		obs_data_item_t *&obj, const media_frames_per_second *valid_fps)
+{
+	media_frames_per_second fps{};
+	if (!FrameRateChangedVariant(w->simpleFPS->currentData(), fps, obj,
+			valid_fps))
+		return false;
+
+	UpdateRationalFPSWidgets(w, &fps);
+	return true;
+}
+
+static bool FrameRateChangedRational(OBSFrameRatePropertyWidget *w,
+		obs_data_item_t *&obj, const media_frames_per_second *valid_fps)
+{
+	auto num = w->numEdit->value();
+	auto den = w->denEdit->value();
+
+	auto fps = make_fps(num, den);
+	if (valid_fps && media_frames_per_second_is_valid(fps) &&
+			fps == *valid_fps)
+		return false;
+
+	obs_data_item_set_frames_per_second(&obj, fps, nullptr);
+	UpdateSimpleFPSSelection(w, &fps);
+	return true;
+}
+
+static bool FrameRateChanged(QWidget *widget, const char *name,
+		OBSData &settings)
+{
+	auto w = qobject_cast<OBSFrameRatePropertyWidget*>(widget);
+	if (!w)
+		return false;
+
+	auto variant = w->modeSelect->currentData();
+	if (!variant.canConvert<frame_rate_tag>())
+		return false;
+
+	auto StopUpdating = [&](void*)
+	{
+		w->updating = false;
+	};
+	unique_ptr<void, decltype(StopUpdating)> signalGuard(
+			static_cast<void*>(w), StopUpdating);
+	w->updating = true;
+
+	if (!obs_data_has_user_value(settings, name))
+		obs_data_set_obj(settings, name, nullptr);
+
+	unique_ptr<obs_data_item_t> obj{obs_data_item_byname(settings, name)};
+	auto obj_ptr = obj.get();
+	auto CheckObj = [&]()
+	{
+		if (!obj_ptr)
+			obj.release();
+	};
+	
+	const char *option = nullptr;
+	obs_data_item_get_frames_per_second(obj.get(), nullptr, &option);
+
+	media_frames_per_second fps{};
+	media_frames_per_second *valid_fps = nullptr;
+	if (obs_data_item_get_frames_per_second(obj.get(), &fps, nullptr))
+		valid_fps = &fps;
+
+	auto tag = variant.value<frame_rate_tag>();
+	switch (tag.type) {
+	case frame_rate_tag::SIMPLE:
+		if (!FrameRateChangedCommon(w, obj_ptr, valid_fps))
+			return false;
+		break;
+
+	case frame_rate_tag::RATIONAL:
+		if (!FrameRateChangedRational(w, obj_ptr, valid_fps))
+			return false;
+		break;
+
+	case frame_rate_tag::USER:
+		if (tag.val && option && strcmp(tag.val, option) == 0)
+			return false;
+
+		obs_data_item_set_frames_per_second(&obj_ptr, {}, tag.val);
+		break;
+	}
+
+	UpdateFPSLabels(w);
+	CheckObj();
+	return true;
+}
+
 void WidgetInfo::BoolChanged(const char *setting)
 {
 	QCheckBox *checkbox = static_cast<QCheckBox*>(widget);
@@ -938,6 +1670,10 @@ void WidgetInfo::ControlChanged()
 			return;
 		break;
 	case OBS_PROPERTY_EDITABLE_LIST: return;
+	case OBS_PROPERTY_FRAME_RATE:
+		if (!FrameRateChanged(widget, setting, view->settings))
+			return;
+		break;
 	}
 
 	if (view->callback && !view->deferUpdate)

+ 2 - 0
obs/properties-view.hpp

@@ -99,6 +99,8 @@ private:
 	QWidget *AddButton(obs_property_t *prop);
 	void AddColor(obs_property_t *prop, QFormLayout *layout, QLabel *&label);
 	void AddFont(obs_property_t *prop, QFormLayout *layout, QLabel *&label);
+	void AddFrameRate(obs_property_t *prop, bool &warning,
+			QFormLayout *layout, QLabel *&label);
 
 	void AddProperty(obs_property_t *property, QFormLayout *layout);
 

+ 58 - 0
obs/properties-view.moc.hpp

@@ -0,0 +1,58 @@
+#pragma once
+
+#include <QComboBox>
+#include <QLabel>
+#include <QSpinBox>
+#include <QStackedWidget>
+#include <QWidget>
+
+#include <media-io/frame-rate.h>
+
+#include <vector>
+
+static bool operator!=(const media_frames_per_second &a,
+		const media_frames_per_second &b)
+{
+	return a.numerator != b.numerator || a.denominator != b.denominator;
+}
+
+static bool operator==(const media_frames_per_second &a,
+		const media_frames_per_second &b)
+{
+	return !(a != b);
+}
+
+using frame_rate_range_t =
+	std::pair<media_frames_per_second, media_frames_per_second>;
+using frame_rate_ranges_t = std::vector<frame_rate_range_t>;
+
+class OBSFrameRatePropertyWidget : public QWidget {
+	Q_OBJECT
+
+public:
+	frame_rate_ranges_t fps_ranges;
+
+	QComboBox      *modeSelect   = nullptr;
+	QStackedWidget *modeDisplay  = nullptr;
+
+	QWidget        *labels       = nullptr;
+	QLabel         *currentFPS   = nullptr;
+	QLabel         *timePerFrame = nullptr;
+	QLabel         *minLabel     = nullptr;
+	QLabel         *maxLabel     = nullptr;
+
+	QComboBox      *simpleFPS    = nullptr;
+
+	QComboBox      *fpsRange     = nullptr;
+	QSpinBox       *numEdit      = nullptr;
+	QSpinBox       *denEdit      = nullptr;
+
+	bool           updating      = false;
+
+	const char     *name         = nullptr;
+	obs_data_t     *settings     = nullptr;
+
+	QLabel         *warningLabel = nullptr;
+
+	OBSFrameRatePropertyWidget() = default;
+};