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