/* * 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 . */ #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 #include #include #include #include #if defined(Q_OS_UNIX) && !defined(Q_OS_MAC) #include #include #include #include #include #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 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 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(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 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