1 /* Copyright (c) 2013-2014 Jeffrey Pfau
2  *
3  * This Source Code Form is subject to the terms of the Mozilla Public
4  * License, v. 2.0. If a copy of the MPL was not distributed with this
5  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6 #include "VideoView.h"
7 
8 #ifdef USE_FFMPEG
9 
10 #include "GBAApp.h"
11 #include "LogController.h"
12 #include "utils.h"
13 
14 #include <mgba-util/math.h>
15 
16 #include <QMap>
17 
18 using namespace QGBA;
19 
20 QMap<QString, QString> VideoView::s_acodecMap;
21 QMap<QString, QString> VideoView::s_vcodecMap;
22 QMap<QString, QString> VideoView::s_containerMap;
23 
compatible(const Preset & other) const24 bool VideoView::Preset::compatible(const Preset& other) const {
25 	if (!other.container.isNull() && !container.isNull() && other.container != container) {
26 		return false;
27 	}
28 	if (!other.acodec.isNull() && !acodec.isNull() && other.acodec != acodec) {
29 		return false;
30 	}
31 	if (!other.vcodec.isNull() && !vcodec.isNull() && other.vcodec != vcodec) {
32 		return false;
33 	}
34 	if (other.abr && abr && other.abr != abr) {
35 		return false;
36 	}
37 	if (other.vbr && vbr && other.vbr != vbr) {
38 		return false;
39 	}
40 	if (other.dims.width() && dims.width() && other.dims.width() != dims.width()) {
41 		return false;
42 	}
43 	if (other.dims.height() && dims.height() && other.dims.height() != dims.height()) {
44 		return false;
45 	}
46 	return true;
47 }
48 
VideoView(QWidget * parent)49 VideoView::VideoView(QWidget* parent)
50 	: QWidget(parent)
51 {
52 	m_ui.setupUi(this);
53 
54 	if (s_acodecMap.empty()) {
55 		s_acodecMap["mp3"] = "libmp3lame";
56 		s_acodecMap["opus"] = "libopus";
57 		s_acodecMap["vorbis"] = "libvorbis";
58 		s_acodecMap["uncompressed"] = "pcm_s16le";
59 	}
60 	if (s_vcodecMap.empty()) {
61 		s_vcodecMap["dirac"] = "libschroedinger";
62 		s_vcodecMap["h264"] = "libx264";
63 		s_vcodecMap["h264 nvenc"] = "h264_nvenc";
64 		s_vcodecMap["hevc"] = "libx265";
65 		s_vcodecMap["hevc nvenc"] = "hevc_nvenc";
66 		s_vcodecMap["theora"] = "libtheora";
67 		s_vcodecMap["vp8"] = "libvpx";
68 		s_vcodecMap["vp9"] = "libvpx-vp9";
69 		s_vcodecMap["xvid"] = "libxvid";
70 	}
71 	if (s_containerMap.empty()) {
72 		s_containerMap["mkv"] = "matroska";
73 	}
74 
75 	connect(m_ui.buttonBox, &QDialogButtonBox::rejected, this, &VideoView::close);
76 	connect(m_ui.start, &QAbstractButton::clicked, this, &VideoView::startRecording);
77 	connect(m_ui.stop, &QAbstractButton::clicked, this, &VideoView::stopRecording);
78 
79 	connect(m_ui.selectFile, &QAbstractButton::clicked, this, &VideoView::selectFile);
80 	connect(m_ui.filename, &QLineEdit::textChanged, this, &VideoView::setFilename);
81 
82 	connect(m_ui.audio, SIGNAL(activated(const QString&)), this, SLOT(setAudioCodec(const QString&)));
83 	connect(m_ui.video, SIGNAL(activated(const QString&)), this, SLOT(setVideoCodec(const QString&)));
84 	connect(m_ui.container, SIGNAL(activated(const QString&)), this, SLOT(setContainer(const QString&)));
85 	connect(m_ui.audio, SIGNAL(editTextChanged(const QString&)), this, SLOT(setAudioCodec(const QString&)));
86 	connect(m_ui.video, SIGNAL(editTextChanged(const QString&)), this, SLOT(setVideoCodec(const QString&)));
87 	connect(m_ui.container, SIGNAL(editTextChanged(const QString&)), this, SLOT(setContainer(const QString&)));
88 
89 	connect(m_ui.abr, static_cast<void (QSpinBox::*)(int)>(&QSpinBox::valueChanged), this, &VideoView::setAudioBitrate);
90 	connect(m_ui.crf, static_cast<void (QSpinBox::*)(int)>(&QSpinBox::valueChanged), this, &VideoView::setVideoRateFactor);
91 	connect(m_ui.vbr, static_cast<void (QSpinBox::*)(int)>(&QSpinBox::valueChanged), this, &VideoView::setVideoBitrate);
92 	connect(m_ui.doVbr, &QAbstractButton::toggled, this, [this](bool set) {
93 		if (set) {
94 			setVideoBitrate(m_ui.vbr->value());
95 		}
96 	});
97 	connect(m_ui.doCrf, &QAbstractButton::toggled, this, [this](bool set) {
98 		if (set) {
99 			setVideoRateFactor(m_ui.crf->value());
100 		}
101 	});
102 
103 	connect(m_ui.width, static_cast<void (QSpinBox::*)(int)>(&QSpinBox::valueChanged), this, &VideoView::setWidth);
104 	connect(m_ui.height, static_cast<void (QSpinBox::*)(int)>(&QSpinBox::valueChanged), this, &VideoView::setHeight);
105 
106 	connect(m_ui.wratio, static_cast<void (QSpinBox::*)(int)>(&QSpinBox::valueChanged), this, &VideoView::setAspectWidth);
107 	connect(m_ui.hratio, static_cast<void (QSpinBox::*)(int)>(&QSpinBox::valueChanged), this, &VideoView::setAspectHeight);
108 
109 	connect(m_ui.showAdvanced, &QAbstractButton::clicked, this, &VideoView::showAdvanced);
110 
111 	FFmpegEncoderInit(&m_encoder);
112 
113 	updatePresets();
114 
115 	m_ui.presetYoutube->setChecked(true); // Use the Youtube preset by default
116 	showAdvanced(false);
117 }
118 
updatePresets()119 void VideoView::updatePresets() {
120 	m_presets.clear();
121 
122 	addPreset(m_ui.preset4K, { maintainAspect(QSize(3840, 2160)) });
123 	addPreset(m_ui.preset1080, { maintainAspect(QSize(1920, 1080)) });
124 	addPreset(m_ui.preset720, { maintainAspect(QSize(1280, 720)) });
125 	addPreset(m_ui.preset480, { maintainAspect(QSize(720, 480)) });
126 
127 	if (m_nativeWidth && m_nativeHeight) {
128 		addPreset(m_ui.presetNative, { QSize(m_nativeWidth, m_nativeHeight) });
129 		m_ui.presetNative->setEnabled(true);
130 	}
131 
132 	addPreset(m_ui.presetHQ, {
133 		"MP4",
134 		"H.264",
135 		"AAC",
136 		-18,
137 		384,
138 		maintainAspect({ 1920, 1080 })
139 	});
140 
141 	addPreset(m_ui.presetYoutube, {
142 		"MP4",
143 		"H.264",
144 		"AAC",
145 		-20,
146 		256,
147 		maintainAspect({ 1280, 720 })
148 	});
149 
150 	addPreset(m_ui.presetWebM, {
151 		"WebM",
152 		"VP9",
153 		"Opus",
154 		800,
155 		128
156 	});
157 
158 	addPreset(m_ui.presetMP4, {
159 		"MP4",
160 		"H.264",
161 		"AAC",
162 		-22,
163 		128
164 	});
165 
166 	if (m_nativeWidth && m_nativeHeight) {
167 		addPreset(m_ui.presetLossless, {
168 			"MKV",
169 			"libx264rgb",
170 			"FLAC",
171 			-1,
172 			0,
173 			{ m_nativeWidth, m_nativeHeight }
174 		});
175 	}
176 }
177 
~VideoView()178 VideoView::~VideoView() {
179 	stopRecording();
180 	free(m_audioCodecCstr);
181 	free(m_videoCodecCstr);
182 	free(m_containerCstr);
183 }
184 
setController(std::shared_ptr<CoreController> controller)185 void VideoView::setController(std::shared_ptr<CoreController> controller) {
186 	CoreController* controllerPtr = controller.get();
187 	connect(controllerPtr, &CoreController::frameAvailable, this, [this, controllerPtr]() {
188 		setNativeResolution(controllerPtr->screenDimensions());
189 	});
190 	connect(controllerPtr, &CoreController::stopping, this, &VideoView::stopRecording);
191 	connect(this, &VideoView::recordingStarted, controllerPtr, &CoreController::setAVStream);
192 	connect(this, &VideoView::recordingStopped, controllerPtr, &CoreController::clearAVStream, Qt::DirectConnection);
193 
194 	setNativeResolution(controllerPtr->screenDimensions());
195 }
196 
startRecording()197 void VideoView::startRecording() {
198 	if (!validateSettings()) {
199 		return;
200 	}
201 	if (!FFmpegEncoderOpen(&m_encoder, m_filename.toUtf8().constData())) {
202 		LOG(QT, ERROR) << tr("Failed to open output video file: %1").arg(m_filename);
203 		return;
204 	}
205 	m_ui.start->setEnabled(false);
206 	m_ui.stop->setEnabled(true);
207 	emit recordingStarted(&m_encoder.d);
208 }
209 
stopRecording()210 void VideoView::stopRecording() {
211 	emit recordingStopped();
212 	FFmpegEncoderClose(&m_encoder);
213 	m_ui.stop->setEnabled(false);
214 	validateSettings();
215 }
216 
setNativeResolution(const QSize & dims)217 void VideoView::setNativeResolution(const QSize& dims) {
218 	if (dims.width() == m_nativeWidth && dims.height() == m_nativeHeight) {
219 		return;
220 	}
221 	m_nativeWidth = dims.width();
222 	m_nativeHeight = dims.height();
223 	m_ui.presetNative->setText(tr("Native (%0x%1)").arg(m_nativeWidth).arg(m_nativeHeight));
224 	QSize newSize = maintainAspect(QSize(m_width, m_height));
225 	m_width = newSize.width();
226 	m_height = newSize.height();
227 	updateAspectRatio(m_nativeWidth, m_nativeHeight, false);
228 	updatePresets();
229 	for (auto iterator = m_presets.constBegin(); iterator != m_presets.constEnd(); ++iterator) {
230 		if (iterator.key()->isChecked()) {
231 			setPreset(*iterator);
232 			break;
233 		}
234 	}
235 }
236 
selectFile()237 void VideoView::selectFile() {
238 	QString filename = GBAApp::app()->getSaveFileName(this, tr("Select output file"));
239 	if (!filename.isEmpty()) {
240 		m_ui.filename->setText(filename);
241 	}
242 }
243 
setFilename(const QString & fname)244 void VideoView::setFilename(const QString& fname) {
245 	m_filename = fname;
246 	validateSettings();
247 }
248 
setAudioCodec(const QString & codec)249 void VideoView::setAudioCodec(const QString& codec) {
250 	free(m_audioCodecCstr);
251 	m_audioCodec = sanitizeCodec(codec, s_acodecMap);
252 	if (m_audioCodec == "none") {
253 		m_audioCodecCstr = nullptr;
254 	} else {
255 		m_audioCodecCstr = strdup(m_audioCodec.toUtf8().constData());
256 	}
257 	if (!FFmpegEncoderSetAudio(&m_encoder, m_audioCodecCstr, 128 * 1024)) {
258 		free(m_audioCodecCstr);
259 		m_audioCodecCstr = nullptr;
260 		m_audioCodec = QString();
261 	}
262 	validateSettings();
263 	uncheckIncompatible();
264 }
265 
setVideoCodec(const QString & codec)266 void VideoView::setVideoCodec(const QString& codec) {
267 	free(m_videoCodecCstr);
268 	m_videoCodec = sanitizeCodec(codec, s_vcodecMap);
269 	if (m_videoCodec == "none") {
270 		m_videoCodecCstr = nullptr;
271 	} else {
272 		m_videoCodecCstr = strdup(m_videoCodec.toUtf8().constData());
273 	}
274 	if (!FFmpegEncoderSetVideo(&m_encoder, m_videoCodecCstr, 1024 * 1024, 0)) {
275 		free(m_videoCodecCstr);
276 		m_videoCodecCstr = nullptr;
277 		m_videoCodec = QString();
278 	}
279 	validateSettings();
280 	uncheckIncompatible();
281 }
282 
setContainer(const QString & container)283 void VideoView::setContainer(const QString& container) {
284 	free(m_containerCstr);
285 	m_container = sanitizeCodec(container, s_containerMap);
286 	m_containerCstr = strdup(m_container.toUtf8().constData());
287 	if (!FFmpegEncoderSetContainer(&m_encoder, m_containerCstr)) {
288 		free(m_containerCstr);
289 		m_containerCstr = nullptr;
290 		m_container = QString();
291 	}
292 	validateSettings();
293 	uncheckIncompatible();
294 }
295 
setAudioBitrate(int br)296 void VideoView::setAudioBitrate(int br) {
297 	m_abr = br * 1000;
298 	FFmpegEncoderSetAudio(&m_encoder, m_audioCodecCstr, m_abr);
299 	validateSettings();
300 	uncheckIncompatible();
301 }
302 
setVideoBitrate(int br)303 void VideoView::setVideoBitrate(int br) {
304 	m_vbr = br > 0 ? br * 1000 : br;
305 	FFmpegEncoderSetVideo(&m_encoder, m_videoCodecCstr, m_vbr, 0);
306 	validateSettings();
307 	uncheckIncompatible();
308 }
309 
setVideoRateFactor(int rf)310 void VideoView::setVideoRateFactor(int rf) {
311 	setVideoBitrate(-rf);
312 }
313 
setWidth(int width)314 void VideoView::setWidth(int width) {
315 	m_width = width;
316 	updateAspectRatio(width, 0, false);
317 	FFmpegEncoderSetDimensions(&m_encoder, m_width, m_height);
318 	uncheckIncompatible();
319 }
320 
setHeight(int height)321 void VideoView::setHeight(int height) {
322 	m_height = height;
323 	updateAspectRatio(0, height, false);
324 	FFmpegEncoderSetDimensions(&m_encoder, m_width, m_height);
325 	uncheckIncompatible();
326 }
327 
setAspectWidth(int)328 void VideoView::setAspectWidth(int) {
329 	updateAspectRatio(0, m_height, true);
330 	FFmpegEncoderSetDimensions(&m_encoder, m_width, m_height);
331 	uncheckIncompatible();
332 }
333 
setAspectHeight(int)334 void VideoView::setAspectHeight(int) {
335 	updateAspectRatio(m_width, 0, true);
336 	FFmpegEncoderSetDimensions(&m_encoder, m_width, m_height);
337 	uncheckIncompatible();
338 }
339 
showAdvanced(bool show)340 void VideoView::showAdvanced(bool show) {
341 	m_ui.advancedBox->setVisible(show);
342 }
343 
validateSettings()344 bool VideoView::validateSettings() {
345 	bool valid = !m_filename.isNull() && !FFmpegEncoderIsOpen(&m_encoder);
346 	if (m_audioCodec.isNull()) {
347 		valid = false;
348 		m_ui.audio->setStyleSheet("QComboBox { color: red; }");
349 	} else {
350 		m_ui.audio->setStyleSheet("");
351 		if (!FFmpegEncoderSetAudio(&m_encoder, m_audioCodecCstr, m_abr)) {
352 			m_ui.abr->setStyleSheet("QSpinBox { color: red; }");
353 		} else {
354 			m_ui.abr->setStyleSheet("");
355 		}
356 	}
357 
358 	if (m_videoCodec.isNull()) {
359 		valid = false;
360 		m_ui.video->setStyleSheet("QComboBox { color: red; }");
361 	} else {
362 		m_ui.video->setStyleSheet("");
363 		if (!FFmpegEncoderSetVideo(&m_encoder, m_videoCodecCstr, m_vbr, 0)) {
364 			if (m_ui.doVbr->isChecked()) {
365 				m_ui.vbr->setStyleSheet("QSpinBox { color: red; }");
366 			} else {
367 				m_ui.vbr->setStyleSheet("");
368 			}
369 			if (m_ui.doCrf->isChecked()) {
370 				m_ui.crf->setStyleSheet("QSpinBox { color: red; }");
371 			} else {
372 				m_ui.crf->setStyleSheet("");
373 			}
374 		} else {
375 			m_ui.vbr->setStyleSheet("");
376 			m_ui.crf->setStyleSheet("");
377 		}
378 	}
379 
380 	if (m_container.isNull()) {
381 		valid = false;
382 		m_ui.container->setStyleSheet("QComboBox { color: red; }");
383 	} else {
384 		m_ui.container->setStyleSheet("");
385 	}
386 
387 	// This |valid| check is necessary as if one of the cstrs
388 	// is null, the encoder likely has a dangling pointer
389 	if (valid && !FFmpegEncoderVerifyContainer(&m_encoder)) {
390 		valid = false;
391 	}
392 
393 	m_ui.start->setEnabled(valid);
394 
395 	return valid;
396 }
397 
updateAspectRatio(int width,int height,bool force)398 void VideoView::updateAspectRatio(int width, int height, bool force) {
399 	if (m_ui.lockRatio->isChecked() || force) {
400 		if (width) {
401 			height = m_ui.hratio->value() * width / m_ui.wratio->value();
402 		} else if (height) {
403 			width = m_ui.wratio->value() * height / m_ui.hratio->value();
404 		}
405 
406 		m_width = width;
407 		m_height = height;
408 		safelySet(m_ui.width, m_width);
409 		safelySet(m_ui.height, m_height);
410 	} else {
411 		int w = m_width;
412 		int h = m_height;
413 		reduceFraction(&h, &w);
414 		safelySet(m_ui.wratio, w);
415 		safelySet(m_ui.hratio, h);
416 	}
417 }
418 
uncheckIncompatible()419 void VideoView::uncheckIncompatible() {
420 	if (m_updatesBlocked) {
421 		return;
422 	}
423 
424 	Preset current = {
425 		m_container,
426 		m_videoCodec,
427 		m_audioCodec,
428 		m_vbr > 0 ? m_vbr / 1000 : m_vbr,
429 		m_abr / 1000,
430 		{ m_width, m_height }
431 	};
432 
433 	m_ui.presets->setExclusive(false);
434 	m_ui.resolutions->setExclusive(false);
435 	for (auto iterator = m_presets.constBegin(); iterator != m_presets.constEnd(); ++iterator) {
436 		Preset next = *iterator;
437 		next.container = sanitizeCodec(next.container, s_containerMap);
438 		next.acodec = sanitizeCodec(next.acodec, s_acodecMap);
439 		next.vcodec = sanitizeCodec(next.vcodec, s_vcodecMap);
440 		if (!current.compatible(next)) {
441 			safelyCheck(iterator.key(), false);
442 		}
443 	}
444 	m_ui.presets->setExclusive(true);
445 	m_ui.resolutions->setExclusive(true);
446 
447 	if (current.compatible(m_presets[m_ui.presetNative])) {
448 		safelyCheck(m_ui.presetNative);
449 	}
450 	if (current.compatible(m_presets[m_ui.preset480])) {
451 		safelyCheck(m_ui.preset480);
452 	}
453 	if (current.compatible(m_presets[m_ui.preset720])) {
454 		safelyCheck(m_ui.preset720);
455 	}
456 	if (current.compatible(m_presets[m_ui.preset1080])) {
457 		safelyCheck(m_ui.preset1080);
458 	}
459 }
460 
sanitizeCodec(const QString & codec,const QMap<QString,QString> & mapping)461 QString VideoView::sanitizeCodec(const QString& codec, const QMap<QString, QString>& mapping) {
462 	QString sanitized = codec.toLower();
463 	sanitized = sanitized.remove(QChar('.'));
464 	sanitized = sanitized.remove(QChar('('));
465 	sanitized = sanitized.remove(QChar(')'));
466 	if (mapping.contains(sanitized)) {
467 		sanitized = mapping[sanitized];
468 	}
469 	return sanitized;
470 }
471 
safelyCheck(QAbstractButton * button,bool set)472 void VideoView::safelyCheck(QAbstractButton* button, bool set) {
473 	QSignalBlocker blocker(button);
474 	bool autoExclusive = button->autoExclusive();
475 	button->setAutoExclusive(false);
476 	button->setChecked(set);
477 	button->setAutoExclusive(autoExclusive);
478 }
479 
safelySet(QSpinBox * box,int value)480 void VideoView::safelySet(QSpinBox* box, int value) {
481 	QSignalBlocker blocker(box);
482 	box->setValue(value);
483 }
484 
safelySet(QComboBox * box,const QString & value)485 void VideoView::safelySet(QComboBox* box, const QString& value) {
486 	QSignalBlocker blocker(box);
487 	box->lineEdit()->setText(value);
488 }
489 
addPreset(QAbstractButton * button,const Preset & preset)490 void VideoView::addPreset(QAbstractButton* button, const Preset& preset) {
491 	m_presets[button] = preset;
492 	button->disconnect();
493 	connect(button, &QAbstractButton::pressed, [this, preset]() {
494 		setPreset(preset);
495 	});
496 }
497 
setPreset(const Preset & preset)498 void VideoView::setPreset(const Preset& preset) {
499 	m_updatesBlocked = true;
500 	if (!preset.container.isNull()) {
501 		setContainer(preset.container);
502 		safelySet(m_ui.container, preset.container);
503 	}
504 	if (!preset.acodec.isNull()) {
505 		setAudioCodec(preset.acodec);
506 		safelySet(m_ui.audio, preset.acodec);
507 	}
508 	if (!preset.vcodec.isNull()) {
509 		setVideoCodec(preset.vcodec);
510 		safelySet(m_ui.video, preset.vcodec);
511 	}
512 	if (preset.abr) {
513 		setAudioBitrate(preset.abr);
514 		safelySet(m_ui.abr, preset.abr);
515 	}
516 	if (preset.vbr) {
517 		int vbr = preset.vbr;
518 		if (vbr == -1) {
519 			vbr = 0;
520 		}
521 		setVideoBitrate(vbr);
522 		if (vbr > 0) {
523 			safelySet(m_ui.vbr, vbr);
524 			m_ui.doVbr->setChecked(true);
525 		} else {
526 			safelySet(m_ui.crf, -vbr);
527 			m_ui.doCrf->setChecked(true);
528 		}
529 	}
530 	if (preset.dims.width() > 0) {
531 		setWidth(preset.dims.width());
532 		safelySet(m_ui.width, preset.dims.width());
533 	}
534 	if (preset.dims.height() > 0) {
535 		setHeight(preset.dims.height());
536 		safelySet(m_ui.height, preset.dims.height());
537 	}
538 	m_updatesBlocked = false;
539 
540 	uncheckIncompatible();
541 	validateSettings();
542 }
543 
maintainAspect(const QSize & size)544 QSize VideoView::maintainAspect(const QSize& size) {
545 	QSize ds = size;
546 	lockAspectRatio(QSize(m_nativeWidth, m_nativeHeight), ds);
547 	return ds;
548 }
549 
550 #endif
551