521 lines
18 KiB
C++
521 lines
18 KiB
C++
/*
|
|
* Copyright (c) 2025 Meltytech, LLC
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
#include "screencapturejob.h"
|
|
|
|
#include "Logger.h"
|
|
#include "ffmpegjob.h"
|
|
#include "jobqueue.h"
|
|
#include "mainwindow.h"
|
|
#include "postjobaction.h"
|
|
#include "screencapture/screencapture.h"
|
|
#include "settings.h"
|
|
|
|
#include <QApplication>
|
|
#include <QDir>
|
|
#include <QFileInfo>
|
|
#include <QStandardPaths>
|
|
#include <QTimer>
|
|
#if defined(Q_OS_UNIX) && !defined(Q_OS_MAC)
|
|
#include <QDBusConnection>
|
|
#include <QDBusMessage>
|
|
#include <QDBusPendingCall>
|
|
#include <QDBusPendingCallWatcher>
|
|
#include <QDBusPendingReply>
|
|
#endif
|
|
|
|
ScreenCaptureJob::ScreenCaptureJob(const QString &name,
|
|
const QString &filename,
|
|
const QRect &captureRect,
|
|
bool recordAudio)
|
|
: AbstractJob(name, QThread::NormalPriority)
|
|
, m_filename(filename)
|
|
, m_rect(captureRect)
|
|
, m_isAutoOpen(true)
|
|
, m_recordAudio(recordAudio)
|
|
{
|
|
QAction *action = new QAction(tr("Open"), this);
|
|
action->setToolTip(tr("Open the capture"));
|
|
action->setData("Open");
|
|
connect(action, SIGNAL(triggered(bool)), this, SLOT(onOpenTriggered()));
|
|
m_successActions << action;
|
|
m_standardActions.clear();
|
|
}
|
|
|
|
ScreenCaptureJob::~ScreenCaptureJob() {}
|
|
|
|
void ScreenCaptureJob::start()
|
|
{
|
|
LOG_DEBUG() << "starting screen capture job";
|
|
|
|
// Create and start progress timer
|
|
connect(&m_progressTimer, &QTimer::timeout, this, [=]() {
|
|
auto secs = time().elapsed() / 1000;
|
|
emit progressUpdated(m_item, -secs);
|
|
});
|
|
m_progressTimer.start(1000); // Update every second
|
|
|
|
QStringList args;
|
|
#ifdef Q_OS_MAC
|
|
args << "-C";
|
|
args << "-g";
|
|
args << "-k";
|
|
args << "-v";
|
|
args << m_filename;
|
|
LOG_DEBUG() << "screencapture " + args.join(' ');
|
|
AbstractJob::start("screencapture", args);
|
|
#else
|
|
#if defined(Q_OS_UNIX) && !defined(Q_OS_MAC)
|
|
// On Linux, check if we're on Wayland and try D-Bus first
|
|
if (ScreenCapture::isWayland()) {
|
|
if (startWaylandRecording()) {
|
|
AbstractJob::start("sleep", {"infinity"});
|
|
return;
|
|
}
|
|
LOG_WARNING() << "Wayland D-Bus recording failed, falling back to ffmpeg (may not work)";
|
|
}
|
|
#endif
|
|
QString vcodec("libx264");
|
|
args << "-f"
|
|
#ifdef Q_OS_WIN
|
|
<< "gdigrab";
|
|
args << "-offset_x" << QString::number(m_rect.x());
|
|
args << "-offset_y" << QString::number(m_rect.y());
|
|
args << "-framerate" << QString::number(MLT.profile().fps());
|
|
args << "-video_size" << QString("%1x%2").arg(m_rect.width()).arg(m_rect.height());
|
|
args << "-i"
|
|
<< "desktop";
|
|
if (m_recordAudio) {
|
|
args << "-f"
|
|
<< "dshow";
|
|
args << "-i"
|
|
<< "audio=" + Settings.audioInput();
|
|
}
|
|
if (Settings.encodeUseHardware() && !Settings.encodeHardware().isEmpty()) {
|
|
vcodec = "h264_mf";
|
|
args << "-rate_control"
|
|
<< "quality";
|
|
args << "-q:v"
|
|
<< "90";
|
|
args << "-pix_fmt"
|
|
<< "nv12";
|
|
args << "-hw_encoding"
|
|
<< "true";
|
|
#else
|
|
<< "x11grab";
|
|
args << "-grab_x" << QString::number(m_rect.x());
|
|
args << "-grab_y" << QString::number(m_rect.y());
|
|
args << "-framerate" << QString::number(MLT.profile().fps());
|
|
args << "-video_size" << QString("%1x%2").arg(m_rect.width()).arg(m_rect.height());
|
|
args << "-i"
|
|
<< ":0.0";
|
|
if (m_recordAudio) {
|
|
args << "-f"
|
|
<< "pulse";
|
|
args << "-i" << Settings.audioInput();
|
|
}
|
|
if (Settings.encodeUseHardware() && !Settings.encodeHardware().isEmpty()) {
|
|
if (Settings.encodeHardware().contains("h264_nvenc")) {
|
|
vcodec = "h264_nvenc";
|
|
args << "-preset"
|
|
<< "p4";
|
|
args << "-tune"
|
|
<< "hq";
|
|
args << "-rc"
|
|
<< "vbr";
|
|
args << "-cq"
|
|
<< "19";
|
|
args << "-b:v"
|
|
<< "0";
|
|
} else if (Settings.encodeHardware().contains("h264_vaapi")) {
|
|
vcodec = "h264_vaapi";
|
|
args << "-qp"
|
|
<< "18";
|
|
args << "-init_hw_device"
|
|
<< "vaapi=vaapi0:";
|
|
args << "-filter_hw_device"
|
|
<< "vaapi0";
|
|
args << "-vf"
|
|
<< "format=nv12,hwupload";
|
|
args << "-quality"
|
|
<< "1";
|
|
args << "-rc_mode"
|
|
<< "CQP";
|
|
}
|
|
#endif
|
|
} else {
|
|
args << "-crf"
|
|
<< "18";
|
|
args << "-preset"
|
|
<< "veryfast";
|
|
args << "-tune"
|
|
<< "film";
|
|
args << "-pix_fmt"
|
|
<< "yuv420p";
|
|
}
|
|
args << "-codec:v" << vcodec;
|
|
if (vcodec == "h264_mf") {
|
|
args << "-profile:v"
|
|
<< "100";
|
|
} else {
|
|
args << "-profile:v"
|
|
<< "high";
|
|
}
|
|
args << "-bf"
|
|
<< "2";
|
|
args << "-g" << QString::number(qRound(2.0 * MLT.profile().fps()));
|
|
args << "-color_range"
|
|
<< "jpeg";
|
|
args << "-color_primaries"
|
|
<< "bt709";
|
|
args << "-color_trc"
|
|
<< "bt709";
|
|
args << "-colorspace"
|
|
<< "bt709";
|
|
args << "-y" << m_filename;
|
|
QString shotcutPath = qApp->applicationDirPath();
|
|
QFileInfo ffmpegPath(shotcutPath, "ffmpeg");
|
|
setReadChannel(QProcess::StandardError);
|
|
LOG_DEBUG() << ffmpegPath.absoluteFilePath() + " " + args.join(' ');
|
|
AbstractJob::start(ffmpegPath.absoluteFilePath(), args);
|
|
#endif
|
|
}
|
|
|
|
void ScreenCaptureJob::stop()
|
|
{
|
|
#if defined(Q_OS_UNIX) && !defined(Q_OS_MAC)
|
|
if (m_dbusService == DBusService::GNOME) {
|
|
// Stop GNOME screencast
|
|
auto bus = QDBusConnection::sessionBus();
|
|
QDBusMessage msg = QDBusMessage::createMethodCall("org.gnome.Shell.Screencast",
|
|
"/org/gnome/Shell/Screencast",
|
|
"org.gnome.Shell.Screencast",
|
|
"StopScreencast");
|
|
QDBusPendingCall async = bus.asyncCall(msg);
|
|
QDBusPendingCallWatcher *watcher = new QDBusPendingCallWatcher(async, this);
|
|
connect(watcher,
|
|
&QDBusPendingCallWatcher::finished,
|
|
this,
|
|
[this](QDBusPendingCallWatcher *w) {
|
|
QDBusPendingReply<bool> reply = *w;
|
|
LOG_DEBUG() << "GNOME Screencast stopped";
|
|
// emit finished(this, true);
|
|
w->deleteLater();
|
|
});
|
|
} else if (m_dbusService == DBusService::KDE) {
|
|
// For KDE Spectacle, user stops recording via the Spectacle UI
|
|
// We just wait for the signals
|
|
LOG_DEBUG() << "Waiting for KDE Spectacle to finish recording (user controlled)";
|
|
}
|
|
if (m_dbusService != DBusService::None) {
|
|
AbstractJob::stop();
|
|
setKilled(false);
|
|
return;
|
|
}
|
|
#endif
|
|
// Try to terminate gracefully
|
|
write("q");
|
|
QTimer::singleShot(1000, this, [this]() {
|
|
if (m_progressTimer.isActive())
|
|
AbstractJob::stop();
|
|
});
|
|
}
|
|
|
|
void ScreenCaptureJob::onOpenTriggered()
|
|
{
|
|
MAIN.open(m_filename);
|
|
}
|
|
|
|
void ScreenCaptureJob::onFinished(int exitCode, QProcess::ExitStatus exitStatus)
|
|
{
|
|
m_progressTimer.stop();
|
|
|
|
LOG_DEBUG() << "screen capture job finished with exit code" << exitCode;
|
|
#if defined(Q_OS_UNIX) && !defined(Q_OS_MAC)
|
|
if (m_dbusService == DBusService::GNOME) {
|
|
LOG_INFO() << "job succeeeded";
|
|
appendToLog(QStringLiteral("Completed successfully in %1\n")
|
|
.arg(QTime::fromMSecsSinceStartOfDay(time().elapsed()).toString()));
|
|
emit progressUpdated(m_item, 100);
|
|
emit finished(this, true);
|
|
|
|
if (!m_actualFilename.endsWith(".mp4")) {
|
|
// Remux the file from Matroska to chosen WebM
|
|
QFileInfo fileInfo(m_filename);
|
|
QString inputFileName = m_actualFilename.isEmpty()
|
|
? fileInfo.path() + "/" + fileInfo.completeBaseName()
|
|
+ ".mkv"
|
|
: m_actualFilename;
|
|
if (m_isAutoOpen && QFileInfo::exists(inputFileName)) {
|
|
m_isAutoOpen = false;
|
|
|
|
// Create FFmpeg remux job
|
|
QStringList args;
|
|
args << "-i" << inputFileName;
|
|
args << "-c"
|
|
<< "copy";
|
|
args << "-y" << m_filename;
|
|
|
|
FfmpegJob *remuxJob = new FfmpegJob(m_filename, args, false);
|
|
remuxJob->setLabel(tr("Remux %1").arg(fileInfo.fileName()));
|
|
remuxJob->setPostJobAction(
|
|
new OpenPostJobAction(inputFileName, m_filename, inputFileName));
|
|
JOBS.add(remuxJob);
|
|
|
|
return;
|
|
}
|
|
}
|
|
if (!m_actualFilename.isEmpty()) {
|
|
m_filename = m_actualFilename;
|
|
}
|
|
}
|
|
if (m_dbusService != DBusService::None) {
|
|
exitCode = 0; // ignore exit code from sleep
|
|
exitStatus = QProcess::NormalExit;
|
|
}
|
|
#endif
|
|
AbstractJob::onFinished(exitCode, exitStatus);
|
|
|
|
if (m_isAutoOpen && exitCode == 0 && QFileInfo::exists(m_filename)) {
|
|
// Automatically open the captured file
|
|
m_isAutoOpen = false;
|
|
QTimer::singleShot(0, this, [this]() { MAIN.open(m_filename); });
|
|
}
|
|
}
|
|
|
|
#if defined(Q_OS_UNIX) && !defined(Q_OS_MAC)
|
|
bool ScreenCaptureJob::startWaylandRecording()
|
|
{
|
|
// Check desktop environment
|
|
QString desktop = qEnvironmentVariable("XDG_CURRENT_DESKTOP").toLower();
|
|
LOG_DEBUG() << "XDG_CURRENT_DESKTOP:" << desktop;
|
|
|
|
// Try GNOME first
|
|
if (desktop.contains("gnome")) {
|
|
if (startGnomeScreencast()) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Try KDE
|
|
if (desktop.contains("kde") || desktop.contains("plasma")) {
|
|
if (startKdeSpectacle()) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// If we reach here, neither GNOME nor KDE worked
|
|
// This shouldn't happen as MainWindow should have launched OBS Studio
|
|
LOG_ERROR() << "Desktop environment not GNOME or KDE, and fallback not available in job";
|
|
return false;
|
|
}
|
|
|
|
bool ScreenCaptureJob::startGnomeScreencast()
|
|
{
|
|
LOG_DEBUG() << "Attempting GNOME Shell Screencast";
|
|
|
|
auto bus = QDBusConnection::sessionBus();
|
|
if (!bus.isConnected()) {
|
|
LOG_WARNING() << "DBus session bus not connected";
|
|
return false;
|
|
}
|
|
|
|
// Check if the service is available
|
|
QDBusMessage checkMsg = QDBusMessage::createMethodCall("org.gnome.Shell.Screencast",
|
|
"/org/gnome/Shell/Screencast",
|
|
"org.freedesktop.DBus.Introspectable",
|
|
"Introspect");
|
|
QDBusPendingCall async = bus.asyncCall(checkMsg, 1000);
|
|
async.waitForFinished();
|
|
QDBusPendingReply<QString> reply = async;
|
|
if (reply.isError()) {
|
|
LOG_WARNING() << "GNOME Shell Screencast service not available:" << reply.error().message();
|
|
return false;
|
|
}
|
|
|
|
// Prepare options
|
|
QVariantMap options;
|
|
options.insert("framerate", static_cast<int>(MLT.profile().fps()));
|
|
options.insert("draw-cursor", true);
|
|
|
|
// Call ScreencastArea
|
|
LOG_DEBUG() << "Recording region:" << m_rect;
|
|
auto msg = QDBusMessage::createMethodCall("org.gnome.Shell.Screencast",
|
|
"/org/gnome/Shell/Screencast",
|
|
"org.gnome.Shell.Screencast",
|
|
"ScreencastArea");
|
|
msg << m_rect.x();
|
|
msg << m_rect.y();
|
|
msg << m_rect.width();
|
|
msg << m_rect.height();
|
|
QFileInfo fileInfo(m_filename);
|
|
msg << fileInfo.path() + "/" + fileInfo.completeBaseName() + ".mkv";
|
|
msg << options;
|
|
QDBusPendingCall screencastCall = bus.asyncCall(msg);
|
|
QDBusPendingCallWatcher *watcher = new QDBusPendingCallWatcher(screencastCall, this);
|
|
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this](QDBusPendingCallWatcher *w) {
|
|
QDBusPendingReply<bool, QString> reply = *w;
|
|
if (reply.isError()) {
|
|
LOG_ERROR() << "GNOME Screencast call failed:" << reply.error().message();
|
|
AbstractJob::stop();
|
|
} else {
|
|
bool success = reply.argumentAt<0>();
|
|
QString filename = reply.argumentAt<1>();
|
|
LOG_DEBUG() << "GNOME Screencast started, success:" << success
|
|
<< "filename:" << filename;
|
|
m_actualFilename = filename;
|
|
}
|
|
w->deleteLater();
|
|
});
|
|
|
|
m_dbusService = DBusService::GNOME;
|
|
LOG_INFO() << "Started GNOME Shell Screencast to:"
|
|
<< fileInfo.path() + "/" + fileInfo.completeBaseName() + ".mkv";
|
|
|
|
// Job will be controlled via stop() method
|
|
return true;
|
|
}
|
|
|
|
bool ScreenCaptureJob::startKdeSpectacle()
|
|
{
|
|
LOG_DEBUG() << "Attempting KDE Spectacle recording";
|
|
|
|
auto bus = QDBusConnection::sessionBus();
|
|
if (!bus.isConnected()) {
|
|
LOG_WARNING() << "DBus session bus not connected";
|
|
return false;
|
|
}
|
|
|
|
// Connect to RecordingTaken and RecordingFailed signals
|
|
bool connected = bus.connect("org.kde.Spectacle",
|
|
"/",
|
|
"org.kde.Spectacle",
|
|
"RecordingTaken",
|
|
this,
|
|
SLOT(onDBusRecordingTaken(QString)));
|
|
if (!connected) {
|
|
LOG_WARNING() << "Failed to connect to RecordingTaken signal";
|
|
return false;
|
|
}
|
|
|
|
connected = bus.connect("org.kde.Spectacle",
|
|
"/",
|
|
"org.kde.Spectacle",
|
|
"RecordingFailed",
|
|
this,
|
|
SLOT(onDBusRecordingFailed()));
|
|
if (!connected) {
|
|
LOG_WARNING() << "Failed to connect to RecordingFailed signal";
|
|
bus.disconnect("org.kde.Spectacle",
|
|
"/",
|
|
"org.kde.Spectacle",
|
|
"RecordingTaken",
|
|
this,
|
|
SLOT(onDBusRecordingTaken(QString)));
|
|
return false;
|
|
}
|
|
|
|
// Call RecordRegion for recording
|
|
LOG_DEBUG() << "Recording region:" << m_rect;
|
|
auto msg = QDBusMessage::createMethodCall("org.kde.Spectacle",
|
|
"/",
|
|
"org.kde.Spectacle",
|
|
m_rect.isNull() ? "RecordScreen" : "RecordRegion");
|
|
msg << 1; // includeMousePointer
|
|
QDBusPendingCall async = bus.asyncCall(msg);
|
|
QDBusPendingCallWatcher *watcher = new QDBusPendingCallWatcher(async, this);
|
|
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this](QDBusPendingCallWatcher *w) {
|
|
QDBusPendingReply<> reply = *w;
|
|
if (reply.isError()) {
|
|
LOG_ERROR() << "KDE Spectacle recording call failed:" << reply.error().message();
|
|
if (m_rect.isNull()) {
|
|
AbstractJob::stop();
|
|
} else {
|
|
m_rect = QRect();
|
|
w->deleteLater();
|
|
startKdeSpectacle();
|
|
}
|
|
} else {
|
|
LOG_DEBUG() << "KDE Spectacle recording initiated";
|
|
}
|
|
w->deleteLater();
|
|
});
|
|
|
|
m_dbusService = DBusService::KDE;
|
|
LOG_INFO() << "Started KDE Spectacle screen recording";
|
|
|
|
return true;
|
|
}
|
|
|
|
void ScreenCaptureJob::onDBusRecordingTaken(const QString &fileName)
|
|
{
|
|
LOG_DEBUG() << "Recording taken, file:" << fileName;
|
|
|
|
// Move/copy the file to our desired location if different
|
|
if (!fileName.isEmpty() && fileName != m_filename) {
|
|
QFile source(fileName);
|
|
if (QFile::exists(m_filename)) {
|
|
QFile::remove(m_filename);
|
|
}
|
|
if (source.copy(m_filename)) {
|
|
LOG_INFO() << "Copied recording from" << fileName << "to" << m_filename;
|
|
source.remove(); // Remove the original
|
|
} else {
|
|
LOG_WARNING() << "Failed to copy recording, using original location";
|
|
m_filename = fileName;
|
|
}
|
|
}
|
|
|
|
auto bus = QDBusConnection::sessionBus();
|
|
bus.disconnect("org.kde.Spectacle",
|
|
"/",
|
|
"org.kde.Spectacle",
|
|
"RecordingTaken",
|
|
this,
|
|
SLOT(onDBusRecordingTaken(QString)));
|
|
bus.disconnect("org.kde.Spectacle",
|
|
"/",
|
|
"org.kde.Spectacle",
|
|
"RecordingFailed",
|
|
this,
|
|
SLOT(onDBusRecordingFailed()));
|
|
|
|
stop();
|
|
}
|
|
|
|
void ScreenCaptureJob::onDBusRecordingFailed()
|
|
{
|
|
LOG_ERROR() << "Recording failed";
|
|
|
|
auto bus = QDBusConnection::sessionBus();
|
|
bus.disconnect("org.kde.Spectacle",
|
|
"/",
|
|
"org.kde.Spectacle",
|
|
"RecordingTaken",
|
|
this,
|
|
SLOT(onDBusRecordingTaken(QString)));
|
|
bus.disconnect("org.kde.Spectacle",
|
|
"/",
|
|
"org.kde.Spectacle",
|
|
"RecordingFailed",
|
|
this,
|
|
SLOT(onDBusRecordingFailed()));
|
|
|
|
AbstractJob::stop();
|
|
}
|
|
#endif
|