Explorar o código

Merge pull request #5371 from norihiro/automatic-file-splitting

Automatic file splitting
Jim %!s(int64=3) %!d(string=hai) anos
pai
achega
b745a100a3

+ 6 - 0
UI/data/locale/en-US.ini

@@ -956,6 +956,12 @@ Basic.Settings.Output.Adv.FFmpeg.AEncoderSettings="Audio Encoder Settings (if an
 Basic.Settings.Output.Adv.FFmpeg.MuxerSettings="Muxer Settings (if any)"
 Basic.Settings.Output.Adv.FFmpeg.GOPSize="Keyframe interval (frames)"
 Basic.Settings.Output.Adv.FFmpeg.IgnoreCodecCompat="Show all codecs (even if potentially incompatible)"
+Basic.Settings.Output.EnableSplitFile="Automatic File Splitting"
+Basic.Settings.Output.SplitFile.TypeTime="Split by Time"
+Basic.Settings.Output.SplitFile.TypeSize="Split by Size"
+Basic.Settings.Output.SplitFile.Time="Split Time"
+Basic.Settings.Output.SplitFile.Size="Split Size"
+Basic.Settings.Output.SplitFile.ResetTimestamps="Reset timestamps at the beginning of each split file"
 
 # Screenshot
 Screenshot="Screenshot Output"

+ 110 - 0
UI/forms/OBSBasicSettings.ui

@@ -2418,6 +2418,95 @@
                             <item row="6" column="1">
                              <widget class="QLineEdit" name="advOutMuxCustom"/>
                             </item>
+                            <item row="7" column="0">
+                             <widget class="QCheckBox" name="advOutSplitFile">
+                              <property name="sizePolicy">
+                               <sizepolicy hsizetype="Minimum" vsizetype="Expanding">
+                                <horstretch>0</horstretch>
+                                <verstretch>0</verstretch>
+                               </sizepolicy>
+                              </property>
+                              <property name="layoutDirection">
+                               <enum>Qt::RightToLeft</enum>
+                              </property>
+                              <property name="text">
+                               <string>Basic.Settings.Output.EnableSplitFile</string>
+                              </property>
+                             </widget>
+                            </item>
+                            <item row="7" column="1">
+                             <widget class="QComboBox" name="advOutSplitFileType">
+                              <property name="enabled">
+                               <bool>false</bool>
+                              </property>
+                              <item>
+                               <property name="text">
+                                <string>Basic.Settings.Output.SplitFile.TypeTime</string>
+                               </property>
+                              </item>
+                              <item>
+                               <property name="text">
+                                <string>Basic.Settings.Output.SplitFile.TypeSize</string>
+                               </property>
+                              </item>
+                             </widget>
+                            </item>
+                            <item row="8" column="0">
+                             <widget class="QLabel" name="advOutSplitFileTimeLabel">
+                              <property name="text">
+                               <string>Basic.Settings.Output.SplitFile.Time</string>
+                              </property>
+                             </widget>
+                            </item>
+                            <item row="8" column="1">
+                             <widget class="QSpinBox" name="advOutSplitFileTime">
+                              <property name="suffix">
+                               <string> s</string>
+                              </property>
+                              <property name="minimum">
+                               <number>5</number>
+                              </property>
+                              <property name="maximum">
+                               <number>21600</number>
+                              </property>
+                              <property name="value">
+                               <number>900</number>
+                              </property>
+                             </widget>
+                            </item>
+                            <item row="9" column="0">
+                             <widget class="QLabel" name="advOutSplitFileSizeLabel">
+                              <property name="text">
+                               <string>Basic.Settings.Output.SplitFile.Size</string>
+                              </property>
+                             </widget>
+                            </item>
+                            <item row="9" column="1">
+                             <widget class="QSpinBox" name="advOutSplitFileSize">
+                              <property name="suffix">
+                               <string> MB</string>
+                              </property>
+                              <property name="minimum">
+                               <number>20</number>
+                              </property>
+                              <property name="maximum">
+                               <number>8192</number>
+                              </property>
+                              <property name="value">
+                               <number>2048</number>
+                              </property>
+                             </widget>
+                            </item>
+                            <item row="10" column="1">
+                             <widget class="QCheckBox" name="advOutSplitFileRstTS">
+                              <property name="text">
+                               <string>Basic.Settings.Output.SplitFile.ResetTimestamps</string>
+                              </property>
+                              <property name="checked">
+                               <bool>true</bool>
+                              </property>
+                             </widget>
+                            </item>
                             <item row="3" column="1">
                              <widget class="QStackedWidget" name="advRecTrackWidget">
                               <property name="sizePolicy">
@@ -5784,6 +5873,11 @@
   <tabstop>advOutRecUseRescale</tabstop>
   <tabstop>advOutRecRescale</tabstop>
   <tabstop>advOutMuxCustom</tabstop>
+  <tabstop>advOutSplitFile</tabstop>
+  <tabstop>advOutSplitFileType</tabstop>
+  <tabstop>advOutSplitFileTime</tabstop>
+  <tabstop>advOutSplitFileSize</tabstop>
+  <tabstop>advOutSplitFileRstTS</tabstop>
   <tabstop>advOutFFType</tabstop>
   <tabstop>advOutFFRecPath</tabstop>
   <tabstop>advOutFFPathBrowse</tabstop>
@@ -6307,5 +6401,21 @@
     </hint>
    </hints>
   </connection>
+  <connection>
+   <sender>advOutSplitFile</sender>
+   <signal>toggled(bool)</signal>
+   <receiver>advOutSplitFileType</receiver>
+   <slot>setEnabled(bool)</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>327</x>
+     <y>355</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>701</x>
+     <y>355</y>
+    </hint>
+   </hints>
+  </connection>
  </connections>
 </ui>

+ 55 - 0
UI/window-basic-main-outputs.cpp

@@ -109,6 +109,20 @@ static void OBSRecordStopping(void *data, calldata_t *params)
 	UNUSED_PARAMETER(params);
 }
 
+static void OBSRecordFileChanged(void *data, calldata_t *params)
+{
+	BasicOutputHandler *output = static_cast<BasicOutputHandler *>(data);
+	const char *next_file = calldata_string(params, "next_file");
+
+	QString arg_last_file =
+		QString::fromUtf8(output->lastRecordingPath.c_str());
+
+	QMetaObject::invokeMethod(output->main, "RecordingFileChanged",
+				  Q_ARG(QString, arg_last_file));
+
+	output->lastRecordingPath = next_file;
+}
+
 static void OBSStartReplayBuffer(void *data, calldata_t *params)
 {
 	BasicOutputHandler *output = static_cast<BasicOutputHandler *>(data);
@@ -1300,6 +1314,8 @@ AdvancedOutput::AdvancedOutput(OBSBasic *main_) : BasicOutputHandler(main_)
 			      OBSStopRecording, this);
 	recordStopping.Connect(obs_output_get_signal_handler(fileOutput),
 			       "stopping", OBSRecordStopping, this);
+	recordFileChanged.Connect(obs_output_get_signal_handler(fileOutput),
+				  "file_changed", OBSRecordFileChanged, this);
 }
 
 void AdvancedOutput::UpdateStreamSettings()
@@ -1823,6 +1839,11 @@ bool AdvancedOutput::StartRecording()
 	const char *filenameFormat;
 	bool noSpace = false;
 	bool overwriteIfExists = false;
+	bool splitFile;
+	const char *splitFileType;
+	int splitFileTime;
+	int splitFileSize;
+	bool splitFileResetTimestamps;
 
 	if (!useStreamEncoder) {
 		if (!ffmpegOutput) {
@@ -1852,6 +1873,8 @@ bool AdvancedOutput::StartRecording()
 					  ffmpegRecording
 						  ? "FFFileNameWithoutSpace"
 						  : "RecFileNameWithoutSpace");
+		splitFile = config_get_bool(main->Config(), "AdvOut",
+					    "RecSplitFile");
 
 		string strPath = GetRecordingFilename(path, recFormat, noSpace,
 						      overwriteIfExists,
@@ -1862,6 +1885,38 @@ bool AdvancedOutput::StartRecording()
 		obs_data_set_string(settings, ffmpegRecording ? "url" : "path",
 				    strPath.c_str());
 
+		if (splitFile) {
+			splitFileType = config_get_string(
+				main->Config(), "AdvOut", "RecSplitFileType");
+			splitFileTime =
+				(astrcmpi(splitFileType, "Time") == 0)
+					? config_get_int(main->Config(),
+							 "AdvOut",
+							 "RecSplitFileTime")
+					: 0;
+			splitFileSize =
+				(astrcmpi(splitFileType, "Size") == 0)
+					? config_get_int(main->Config(),
+							 "AdvOut",
+							 "RecSplitFileSize")
+					: 0;
+			splitFileResetTimestamps =
+				config_get_bool(main->Config(), "AdvOut",
+						"RecSplitFileResetTimestamps");
+			obs_data_set_string(settings, "directory", path);
+			obs_data_set_string(settings, "format", filenameFormat);
+			obs_data_set_string(settings, "extension", recFormat);
+			obs_data_set_bool(settings, "allow_spaces", !noSpace);
+			obs_data_set_bool(settings, "allow_overwrite",
+					  overwriteIfExists);
+			obs_data_set_int(settings, "max_time_sec",
+					 splitFileTime);
+			obs_data_set_int(settings, "max_size_mb",
+					 splitFileSize);
+			obs_data_set_bool(settings, "reset_timestamps",
+					  splitFileResetTimestamps);
+		}
+
 		obs_output_update(fileOutput, settings);
 	}
 

+ 1 - 0
UI/window-basic-main-outputs.hpp

@@ -32,6 +32,7 @@ struct BasicOutputHandler {
 	OBSSignal streamDelayStarting;
 	OBSSignal streamStopping;
 	OBSSignal recordStopping;
+	OBSSignal recordFileChanged;
 	OBSSignal replayBufferStopping;
 	OBSSignal replayBufferSaved;
 

+ 17 - 2
UI/window-basic-main.cpp

@@ -1424,6 +1424,12 @@ bool OBSBasic::InitBasicConfigDefaults()
 	config_set_default_uint(basicConfig, "AdvOut", "Track5Bitrate", 160);
 	config_set_default_uint(basicConfig, "AdvOut", "Track6Bitrate", 160);
 
+	config_set_default_uint(basicConfig, "AdvOut", "RecSplitFileTime", 900);
+	config_set_default_uint(basicConfig, "AdvOut", "RecSplitFileSize",
+				2048);
+	config_set_default_bool(basicConfig, "AdvOut",
+				"RecSplitFileResetTimestamps", true);
+
 	config_set_default_bool(basicConfig, "AdvOut", "RecRB", false);
 	config_set_default_uint(basicConfig, "AdvOut", "RecRBTime", 20);
 	config_set_default_int(basicConfig, "AdvOut", "RecRBSize", 512);
@@ -7055,7 +7061,7 @@ void OBSBasic::StreamingStop(int code, QString last_error)
 		SetBroadcastFlowEnabled(auth && auth->broadcastFlow());
 }
 
-void OBSBasic::AutoRemux(QString input)
+void OBSBasic::AutoRemux(QString input, bool no_show)
 {
 	bool autoRemux = config_get_bool(Config(), "Video", "AutoRemux");
 
@@ -7087,7 +7093,8 @@ void OBSBasic::AutoRemux(QString input)
 	output += "mp4";
 
 	OBSRemux *remux = new OBSRemux(QT_TO_UTF8(path), this, true);
-	remux->show();
+	if (!no_show)
+		remux->show();
 	remux->AutoRemux(input, output);
 }
 
@@ -7240,6 +7247,14 @@ void OBSBasic::RecordingStop(int code, QString last_error)
 	UpdatePause(false);
 }
 
+void OBSBasic::RecordingFileChanged(QString lastRecordingPath)
+{
+	QString str = QTStr("Basic.StatusBar.RecordingSavedTo");
+	ShowStatusBarMessage(str.arg(lastRecordingPath));
+
+	AutoRemux(lastRecordingPath, true);
+}
+
 void OBSBasic::ShowReplayBufferPauseWarning()
 {
 	auto msgBox = []() {

+ 2 - 1
UI/window-basic-main.hpp

@@ -639,6 +639,7 @@ public slots:
 	void RecordingStart();
 	void RecordStopping();
 	void RecordingStop(int code, QString last_error);
+	void RecordingFileChanged(QString lastRecordingPath);
 
 	void ShowReplayBufferPauseWarning();
 	void StartReplayBuffer();
@@ -807,7 +808,7 @@ private:
 
 	static void HotkeyTriggered(void *data, obs_hotkey_id id, bool pressed);
 
-	void AutoRemux(QString input);
+	void AutoRemux(QString input, bool no_show = false);
 
 	void UpdatePause(bool activate = true);
 	void UpdateReplayBuffer(bool activate = true);

+ 57 - 0
UI/window-basic-settings.cpp

@@ -459,6 +459,11 @@ OBSBasicSettings::OBSBasicSettings(QWidget *parent)
 	HookWidget(ui->advOutRecUseRescale,  CHECK_CHANGED,  OUTPUTS_CHANGED);
 	HookWidget(ui->advOutRecRescale,     CBEDIT_CHANGED, OUTPUTS_CHANGED);
 	HookWidget(ui->advOutMuxCustom,      EDIT_CHANGED,   OUTPUTS_CHANGED);
+	HookWidget(ui->advOutSplitFile,      CHECK_CHANGED,  OUTPUTS_CHANGED);
+	HookWidget(ui->advOutSplitFileType,  COMBO_CHANGED,  OUTPUTS_CHANGED);
+	HookWidget(ui->advOutSplitFileTime,  SCROLL_CHANGED, OUTPUTS_CHANGED);
+	HookWidget(ui->advOutSplitFileSize,  SCROLL_CHANGED, OUTPUTS_CHANGED);
+	HookWidget(ui->advOutSplitFileRstTS, CHECK_CHANGED,  OUTPUTS_CHANGED);
 	HookWidget(ui->advOutRecTrack1,      CHECK_CHANGED,  OUTPUTS_CHANGED);
 	HookWidget(ui->advOutRecTrack2,      CHECK_CHANGED,  OUTPUTS_CHANGED);
 	HookWidget(ui->advOutRecTrack3,      CHECK_CHANGED,  OUTPUTS_CHANGED);
@@ -781,6 +786,10 @@ OBSBasicSettings::OBSBasicSettings(QWidget *parent)
 		this, SLOT(SimpleReplayBufferChanged()));
 	connect(ui->simpleRBSecMax, SIGNAL(valueChanged(int)), this,
 		SLOT(SimpleReplayBufferChanged()));
+	connect(ui->advOutSplitFile, SIGNAL(stateChanged(int)), this,
+		SLOT(AdvOutSplitFileChanged()));
+	connect(ui->advOutSplitFileType, SIGNAL(currentIndexChanged(int)), this,
+		SLOT(AdvOutSplitFileChanged()));
 	connect(ui->advReplayBuf, SIGNAL(toggled(bool)), this,
 		SLOT(AdvReplayBufferChanged()));
 	connect(ui->advOutRecTrack1, SIGNAL(toggled(bool)), this,
@@ -907,6 +916,7 @@ OBSBasicSettings::OBSBasicSettings(QWidget *parent)
 	ui->buttonBox->button(QDialogButtonBox::Cancel)->setIcon(QIcon());
 
 	SimpleRecordingQualityChanged();
+	AdvOutSplitFileChanged();
 
 	UpdateAutomaticReplayBufferCheckboxes();
 
@@ -1964,6 +1974,16 @@ void OBSBasicSettings::LoadAdvOutputRecordingSettings()
 		config_get_string(main->Config(), "AdvOut", "RecMuxerCustom");
 	int tracks = config_get_int(main->Config(), "AdvOut", "RecTracks");
 	int flvTrack = config_get_int(main->Config(), "AdvOut", "FLVTrack");
+	bool splitFile =
+		config_get_bool(main->Config(), "AdvOut", "RecSplitFile");
+	const char *splitFileType =
+		config_get_string(main->Config(), "AdvOut", "RecSplitFileType");
+	int splitFileTime =
+		config_get_int(main->Config(), "AdvOut", "RecSplitFileTime");
+	int splitFileSize =
+		config_get_int(main->Config(), "AdvOut", "RecSplitFileSize");
+	bool splitFileResetTimestamps = config_get_bool(
+		main->Config(), "AdvOut", "RecSplitFileResetTimestamps");
 
 	int typeIndex = (astrcmpi(type, "FFmpeg") == 0) ? 1 : 0;
 	ui->advOutRecType->setCurrentIndex(typeIndex);
@@ -1983,6 +2003,13 @@ void OBSBasicSettings::LoadAdvOutputRecordingSettings()
 	ui->advOutRecTrack5->setChecked(tracks & (1 << 4));
 	ui->advOutRecTrack6->setChecked(tracks & (1 << 5));
 
+	idx = (astrcmpi(splitFileType, "Size") == 0) ? 1 : 0;
+	ui->advOutSplitFile->setChecked(splitFile);
+	ui->advOutSplitFileType->setCurrentIndex(idx);
+	ui->advOutSplitFileTime->setValue(splitFileTime);
+	ui->advOutSplitFileSize->setValue(splitFileSize);
+	ui->advOutSplitFileRstTS->setChecked(splitFileResetTimestamps);
+
 	switch (flvTrack) {
 	case 1:
 		ui->flvTrack1->setChecked(true);
@@ -3416,6 +3443,14 @@ static inline const char *RecTypeFromIdx(int idx)
 		return "Standard";
 }
 
+static inline const char *SplitFileTypeFromIdx(int idx)
+{
+	if (idx == 1)
+		return "Size";
+	else
+		return "Time";
+}
+
 static void WriteJsonData(OBSPropertiesView *view, const char *path)
 {
 	char full_path[512];
@@ -3551,6 +3586,14 @@ void OBSBasicSettings::SaveOutputSettings()
 	SaveCheckBox(ui->advOutRecUseRescale, "AdvOut", "RecRescale");
 	SaveCombo(ui->advOutRecRescale, "AdvOut", "RecRescaleRes");
 	SaveEdit(ui->advOutMuxCustom, "AdvOut", "RecMuxerCustom");
+	SaveCheckBox(ui->advOutSplitFile, "AdvOut", "RecSplitFile");
+	config_set_string(
+		main->Config(), "AdvOut", "RecSplitFileType",
+		SplitFileTypeFromIdx(ui->advOutSplitFileType->currentIndex()));
+	SaveSpinBox(ui->advOutSplitFileTime, "AdvOut", "RecSplitFileTime");
+	SaveSpinBox(ui->advOutSplitFileSize, "AdvOut", "RecSplitFileSize");
+	SaveCheckBox(ui->advOutSplitFileRstTS, "AdvOut",
+		     "RecSplitFileResetTimestamps");
 
 	config_set_int(
 		main->Config(), "AdvOut", "RecTracks",
@@ -4544,6 +4587,20 @@ void OBSBasicSettings::AdvancedChanged()
 	}
 }
 
+void OBSBasicSettings::AdvOutSplitFileChanged()
+{
+	bool splitFile = ui->advOutSplitFile->isChecked();
+	int splitFileType = splitFile ? ui->advOutSplitFileType->currentIndex()
+				      : -1;
+
+	ui->advOutSplitFileType->setEnabled(splitFile);
+	ui->advOutSplitFileTimeLabel->setVisible(splitFileType == 0);
+	ui->advOutSplitFileTime->setVisible(splitFileType == 0);
+	ui->advOutSplitFileSizeLabel->setVisible(splitFileType == 1);
+	ui->advOutSplitFileSize->setVisible(splitFileType == 1);
+	ui->advOutSplitFileRstTS->setVisible(splitFile);
+}
+
 void OBSBasicSettings::AdvOutRecCheckWarnings()
 {
 	auto Checked = [](QCheckBox *box) { return box->isChecked() ? 1 : 0; };

+ 1 - 0
UI/window-basic-settings.hpp

@@ -393,6 +393,7 @@ private slots:
 
 	void UpdateAutomaticReplayBufferCheckboxes();
 
+	void AdvOutSplitFileChanged();
 	void AdvOutRecCheckWarnings();
 
 	void SimpleRecordingQualityChanged();

+ 37 - 0
plugins/obs-ffmpeg/ffmpeg-mux/ffmpeg-mux.c

@@ -825,6 +825,35 @@ static inline bool ffmpeg_mux_packet(struct ffmpeg_mux *ffm, uint8_t *buf,
 	return ret >= 0;
 }
 
+static inline bool read_change_file(struct ffmpeg_mux *ffm, uint32_t size,
+				    struct resize_buf *filename, int argc,
+				    char **argv)
+{
+	resize_buf_resize(filename, size + 1);
+	if (safe_read(filename->buf, size) != size) {
+		return false;
+	}
+	filename->buf[size] = 0;
+
+	fprintf(stderr, "info: New output file name: %s\n", filename->buf);
+
+	int ret;
+	char *argv1_backup = argv[1];
+	argv[1] = (char *)filename->buf;
+
+	ffmpeg_mux_free(ffm);
+
+	ret = ffmpeg_mux_init(ffm, argc, argv);
+	if (ret != FFM_SUCCESS) {
+		fprintf(stderr, "Couldn't initialize muxer\n");
+		return false;
+	}
+
+	argv[1] = argv1_backup;
+
+	return true;
+}
+
 /* ------------------------------------------------------------------------- */
 
 #ifdef _WIN32
@@ -836,6 +865,7 @@ int main(int argc, char *argv[])
 	struct ffm_packet_info info = {0};
 	struct ffmpeg_mux ffm = {0};
 	struct resize_buf rb = {0};
+	struct resize_buf rb_filename = {0};
 	bool fail = false;
 	int ret;
 
@@ -868,6 +898,12 @@ int main(int argc, char *argv[])
 	}
 
 	while (!fail && safe_read(&info, sizeof(info)) == sizeof(info)) {
+		if (info.type == FFM_PACKET_CHANGE_FILE) {
+			fail = !read_change_file(&ffm, info.size, &rb_filename,
+						 argc, argv);
+			continue;
+		}
+
 		resize_buf_resize(&rb, info.size);
 
 		if (safe_read(rb.buf, info.size) == info.size) {
@@ -879,6 +915,7 @@ int main(int argc, char *argv[])
 
 	ffmpeg_mux_free(&ffm);
 	resize_buf_free(&rb);
+	resize_buf_free(&rb_filename);
 
 #ifdef _WIN32
 	for (int i = 0; i < argc; i++)

+ 1 - 0
plugins/obs-ffmpeg/ffmpeg-mux/ffmpeg-mux.h

@@ -22,6 +22,7 @@
 enum ffm_packet_type {
 	FFM_PACKET_VIDEO,
 	FFM_PACKET_AUDIO,
+	FFM_PACKET_CHANGE_FILE,
 };
 
 #define FFM_SUCCESS 0

+ 263 - 28
plugins/obs-ffmpeg/obs-ffmpeg-mux.c

@@ -87,6 +87,9 @@ static void *ffmpeg_mux_create(obs_data_t *settings, obs_output_t *output)
 	if (obs_output_get_flags(output) & OBS_OUTPUT_SERVICE)
 		stream->is_network = true;
 
+	signal_handler_t *sh = obs_output_get_signal_handler(output);
+	signal_handler_add(sh, "void file_changed(string next_file)");
+
 	UNUSED_PARAMETER(settings);
 	return stream;
 }
@@ -315,6 +318,40 @@ static void set_file_not_readable_error(struct ffmpeg_muxer *stream,
 	obs_data_release(settings);
 }
 
+inline static void ts_offset_clear(struct ffmpeg_muxer *stream)
+{
+	stream->found_video = false;
+	stream->video_pts_offset = 0;
+
+	for (size_t i = 0; i < MAX_AUDIO_MIXES; i++) {
+		stream->found_audio[i] = false;
+		stream->audio_dts_offsets[i] = 0;
+	}
+}
+
+static inline int64_t packet_pts_usec(struct encoder_packet *packet)
+{
+	return packet->pts * 1000000 / packet->timebase_den;
+}
+
+inline static void ts_offset_update(struct ffmpeg_muxer *stream,
+				    struct encoder_packet *packet)
+{
+	if (packet->type == OBS_ENCODER_VIDEO) {
+		if (!stream->found_video) {
+			stream->video_pts_offset = packet->pts;
+			stream->found_video = true;
+		}
+		return;
+	}
+
+	if (stream->found_audio[packet->track_idx])
+		return;
+
+	stream->audio_dts_offsets[packet->track_idx] = packet->dts;
+	stream->found_audio[packet->track_idx] = true;
+}
+
 static bool ffmpeg_mux_start(void *data)
 {
 	struct ffmpeg_muxer *stream = data;
@@ -333,10 +370,27 @@ static bool ffmpeg_mux_start(void *data)
 		if (!service)
 			return false;
 		path = obs_service_get_url(service);
+		stream->split_file = false;
+		stream->reset_timestamps = false;
 	} else {
 		path = obs_data_get_string(settings, "path");
+
+		stream->max_time =
+			obs_data_get_int(settings, "max_time_sec") * 1000000LL;
+		stream->max_size = obs_data_get_int(settings, "max_size_mb") *
+				   (1024 * 1024);
+		stream->split_file = stream->max_time > 0 ||
+				     stream->max_size > 0;
+		stream->reset_timestamps =
+			obs_data_get_bool(settings, "reset_timestamps");
+		stream->allow_overwrite =
+			obs_data_get_bool(settings, "allow_overwrite");
+		stream->cur_size = 0;
+		stream->sent_headers = false;
 	}
 
+	ts_offset_clear(stream);
+
 	if (!stream->is_network) {
 		/* ensure output path is writable to avoid generic error
 		 * message.
@@ -468,6 +522,64 @@ static void signal_failure(struct ffmpeg_muxer *stream)
 	os_atomic_set_bool(&stream->capturing, false);
 }
 
+static void find_best_filename(struct dstr *path, bool space)
+{
+	int num = 2;
+
+	if (!os_file_exists(path->array))
+		return;
+
+	const char *ext = strrchr(path->array, '.');
+	if (!ext)
+		return;
+
+	size_t extstart = ext - path->array;
+	struct dstr testpath;
+	dstr_init_copy_dstr(&testpath, path);
+	for (;;) {
+		dstr_resize(&testpath, extstart);
+		dstr_catf(&testpath, space ? " (%d)" : "_%d", num++);
+		dstr_cat(&testpath, ext);
+
+		if (!os_file_exists(testpath.array)) {
+			dstr_free(path);
+			dstr_init_move(path, &testpath);
+			break;
+		}
+	}
+}
+
+static void generate_filename(struct ffmpeg_muxer *stream, struct dstr *dst,
+			      bool overwrite)
+{
+	obs_data_t *settings = obs_output_get_settings(stream->output);
+	const char *dir = obs_data_get_string(settings, "directory");
+	const char *fmt = obs_data_get_string(settings, "format");
+	const char *ext = obs_data_get_string(settings, "extension");
+	bool space = obs_data_get_bool(settings, "allow_spaces");
+
+	char *filename = os_generate_formatted_filename(ext, space, fmt);
+
+	dstr_copy(dst, dir);
+	dstr_replace(dst, "\\", "/");
+	if (dstr_end(dst) != '/')
+		dstr_cat_ch(dst, '/');
+	dstr_cat(dst, filename);
+
+	char *slash = strrchr(dst->array, '/');
+	if (slash) {
+		*slash = 0;
+		os_mkdirs(dst->array);
+		*slash = '/';
+	}
+
+	if (!overwrite)
+		find_best_filename(dst, space);
+
+	bfree(filename);
+	obs_data_release(settings);
+}
+
 bool write_packet(struct ffmpeg_muxer *stream, struct encoder_packet *packet)
 {
 	bool is_video = packet->type == OBS_ENCODER_VIDEO;
@@ -481,6 +593,16 @@ bool write_packet(struct ffmpeg_muxer *stream, struct encoder_packet *packet)
 							: FFM_PACKET_AUDIO,
 				       .keyframe = packet->keyframe};
 
+	if (stream->split_file && stream->reset_timestamps) {
+		if (is_video) {
+			info.dts -= stream->video_pts_offset;
+			info.pts -= stream->video_pts_offset;
+		} else {
+			info.dts -= stream->audio_dts_offsets[info.index];
+			info.pts -= stream->audio_dts_offsets[info.index];
+		}
+	}
+
 	ret = os_process_pipe_write(stream->pipe, (const uint8_t *)&info,
 				    sizeof(info));
 	if (ret != sizeof(info)) {
@@ -497,6 +619,10 @@ bool write_packet(struct ffmpeg_muxer *stream, struct encoder_packet *packet)
 	}
 
 	stream->total_bytes += packet->size;
+
+	if (stream->split_file)
+		stream->cur_size += packet->size;
+
 	return true;
 }
 
@@ -542,6 +668,96 @@ bool send_headers(struct ffmpeg_muxer *stream)
 	return true;
 }
 
+static inline bool should_split(struct ffmpeg_muxer *stream,
+				struct encoder_packet *packet)
+{
+	/* split at video frame */
+	if (packet->type != OBS_ENCODER_VIDEO)
+		return false;
+
+	/* don't split group of pictures */
+	if (!packet->keyframe)
+		return false;
+
+	/* reached maximum file size */
+	if (stream->max_size > 0 &&
+	    stream->cur_size + (int64_t)packet->size >= stream->max_size)
+		return true;
+
+	/* reached maximum duration */
+	if (stream->max_time > 0 &&
+	    packet->dts_usec - stream->cur_time >= stream->max_time)
+		return true;
+
+	return false;
+}
+
+static bool send_new_filename(struct ffmpeg_muxer *stream, const char *filename)
+{
+	size_t ret;
+	uint32_t size = strlen(filename);
+	struct ffm_packet_info info = {.type = FFM_PACKET_CHANGE_FILE,
+				       .size = size};
+
+	ret = os_process_pipe_write(stream->pipe, (const uint8_t *)&info,
+				    sizeof(info));
+	if (ret != sizeof(info)) {
+		warn("os_process_pipe_write for info structure failed");
+		signal_failure(stream);
+		return false;
+	}
+
+	ret = os_process_pipe_write(stream->pipe, (const uint8_t *)filename,
+				    size);
+	if (ret != size) {
+		warn("os_process_pipe_write for packet data failed");
+		signal_failure(stream);
+		return false;
+	}
+
+	return true;
+}
+
+static bool prepare_split_file(struct ffmpeg_muxer *stream,
+			       struct encoder_packet *packet)
+{
+	generate_filename(stream, &stream->path, stream->allow_overwrite);
+	info("Changing output file to '%s'", stream->path.array);
+
+	if (!send_new_filename(stream, stream->path.array)) {
+		warn("Failed to send new file name");
+		return false;
+	}
+
+	calldata_t cd = {0};
+	signal_handler_t *sh = obs_output_get_signal_handler(stream->output);
+	calldata_set_string(&cd, "next_file", stream->path.array);
+	signal_handler_signal(sh, "file_changed", &cd);
+	calldata_free(&cd);
+
+	if (!send_headers(stream))
+		return false;
+
+	stream->cur_size = 0;
+	stream->cur_time = packet->dts_usec;
+	ts_offset_clear(stream);
+
+	return true;
+}
+
+static inline bool has_audio(struct ffmpeg_muxer *stream)
+{
+	return !!obs_output_get_audio_encoder(stream->output, 0);
+}
+
+static void push_back_packet(struct darray *packets,
+			     struct encoder_packet *packet)
+{
+	struct encoder_packet pkt;
+	obs_encoder_packet_ref(&pkt, packet);
+	darray_push_back(sizeof(pkt), packets, &pkt);
+}
+
 static void ffmpeg_mux_data(void *data, struct encoder_packet *packet)
 {
 	struct ffmpeg_muxer *stream = data;
@@ -555,11 +771,41 @@ static void ffmpeg_mux_data(void *data, struct encoder_packet *packet)
 		return;
 	}
 
+	if (stream->split_file && stream->mux_packets.num) {
+		int64_t pts_usec = packet_pts_usec(packet);
+		struct encoder_packet *first_pkt = stream->mux_packets.array;
+		int64_t first_pts_usec = packet_pts_usec(first_pkt);
+
+		if (pts_usec >= first_pts_usec) {
+			if (packet->type != OBS_ENCODER_AUDIO) {
+				push_back_packet(&stream->mux_packets.da,
+						 packet);
+				return;
+			}
+
+			if (!prepare_split_file(stream, first_pkt))
+				return;
+			stream->split_file_ready = true;
+		}
+	} else if (stream->split_file && should_split(stream, packet)) {
+		if (has_audio(stream)) {
+			push_back_packet(&stream->mux_packets.da, packet);
+			return;
+		} else {
+			if (!prepare_split_file(stream, packet))
+				return;
+			stream->split_file_ready = true;
+		}
+	}
+
 	if (!stream->sent_headers) {
 		if (!send_headers(stream))
 			return;
 
 		stream->sent_headers = true;
+
+		if (stream->split_file)
+			stream->cur_time = packet->dts_usec;
 	}
 
 	if (stopping(stream)) {
@@ -569,6 +815,22 @@ static void ffmpeg_mux_data(void *data, struct encoder_packet *packet)
 		}
 	}
 
+	if (stream->split_file && stream->split_file_ready) {
+		for (size_t i = 0; i < stream->mux_packets.num; i++) {
+			struct encoder_packet *pkt =
+				&stream->mux_packets.array[i];
+			if (stream->reset_timestamps)
+				ts_offset_update(stream, pkt);
+			write_packet(stream, pkt);
+			obs_encoder_packet_release(pkt);
+		}
+		da_free(stream->mux_packets);
+		stream->split_file_ready = false;
+	}
+
+	if (stream->split_file && stream->reset_timestamps)
+		ts_offset_update(stream, packet);
+
 	write_packet(stream, packet);
 }
 
@@ -918,34 +1180,7 @@ static void replay_buffer_save(struct ffmpeg_muxer *stream)
 			      audio_dts_offsets);
 	}
 
-	/* ---------------------------- */
-	/* generate filename */
-
-	obs_data_t *settings = obs_output_get_settings(stream->output);
-	const char *dir = obs_data_get_string(settings, "directory");
-	const char *fmt = obs_data_get_string(settings, "format");
-	const char *ext = obs_data_get_string(settings, "extension");
-	bool space = obs_data_get_bool(settings, "allow_spaces");
-
-	char *filename = os_generate_formatted_filename(ext, space, fmt);
-
-	dstr_copy(&stream->path, dir);
-	dstr_replace(&stream->path, "\\", "/");
-	if (dstr_end(&stream->path) != '/')
-		dstr_cat_ch(&stream->path, '/');
-	dstr_cat(&stream->path, filename);
-
-	char *slash = strrchr(stream->path.array, '/');
-	if (slash) {
-		*slash = 0;
-		os_mkdirs(stream->path.array);
-		*slash = '/';
-	}
-
-	bfree(filename);
-	obs_data_release(settings);
-
-	/* ---------------------------- */
+	generate_filename(stream, &stream->path, true);
 
 	os_atomic_set_bool(&stream->muxing, true);
 	stream->mux_thread_joinable = pthread_create(&stream->mux_thread, NULL,

+ 13 - 1
plugins/obs-ffmpeg/obs-ffmpeg-mux.h

@@ -24,17 +24,26 @@ struct ffmpeg_muxer {
 	struct dstr muxer_settings;
 	struct dstr stream_key;
 
-	/* replay buffer */
+	/* replay buffer and split file */
 	int64_t cur_size;
 	int64_t cur_time;
 	int64_t max_size;
 	int64_t max_time;
+
+	/* replay buffer */
 	int64_t save_ts;
 	int keyframes;
 	obs_hotkey_id hotkey;
 	volatile bool muxing;
 	DARRAY(struct encoder_packet) mux_packets;
 
+	/* split file */
+	bool found_video;
+	bool found_audio[MAX_AUDIO_MIXES];
+	int64_t video_pts_offset;
+	int64_t audio_dts_offsets[MAX_AUDIO_MIXES];
+	bool split_file_ready;
+
 	/* these are accessed both by replay buffer and by HLS */
 	pthread_t mux_thread;
 	bool mux_thread_joinable;
@@ -51,6 +60,9 @@ struct ffmpeg_muxer {
 	int64_t last_dts_usec;
 
 	bool is_network;
+	bool split_file;
+	bool reset_timestamps;
+	bool allow_overwrite;
 };
 
 bool stopping(struct ffmpeg_muxer *stream);