File indexing completed on 2025-02-02 04:26:14
0001 /* 0002 SPDX-FileCopyrightText: 2023 Aleix Pol Gonzalez <aleixpol@kde.org> 0003 0004 SPDX-License-Identifier: LGPL-2.0-or-later 0005 */ 0006 0007 #include "VideoPlatformWayland.h" 0008 #include "ExportManager.h" 0009 #include "Platforms/VideoPlatform.h" 0010 #include "screencasting.h" 0011 #include "settings.h" 0012 #include <KLocalizedString> 0013 #include <QGuiApplication> 0014 #include <QWindow> 0015 #include <QScreen> 0016 #include <QDebug> 0017 #include <QDBusConnection> 0018 #include <QDBusMessage> 0019 #include <QDBusPendingCallWatcher> 0020 #include <QDBusPendingReply> 0021 #include <QDBusReply> 0022 #include <QStandardPaths> 0023 #include <QTemporaryDir> 0024 #include <QUrl> 0025 0026 using namespace Qt::StringLiterals; 0027 0028 using Format = VideoPlatform::Format; 0029 using Formats = VideoPlatform::Formats; 0030 using Encoder = PipeWireBaseEncodedStream::Encoder; 0031 0032 VideoPlatform::Format VideoPlatformWayland::formatForEncoder(Encoder encoder) const 0033 { 0034 switch (encoder) { 0035 case Encoder::VP9: return WebM_VP9; 0036 case Encoder::H264Main: return MP4_H264; 0037 case Encoder::H264Baseline: return MP4_H264; 0038 default: return NoFormat; 0039 } 0040 } 0041 0042 PipeWireBaseEncodedStream::Encoder VideoPlatformWayland::encoderForFormat(Format format) const 0043 { 0044 const auto encoders = m_recorder->suggestedEncoders(); 0045 if (format == WebM_VP9 && encoders.contains(Encoder::VP9)) { 0046 return Encoder::VP9; 0047 } 0048 if (format == MP4_H264) { 0049 if (encoders.contains(Encoder::H264Main)) { 0050 return Encoder::H264Main; 0051 } 0052 if (encoders.contains(Encoder::H264Baseline)) { 0053 return Encoder::H264Baseline; 0054 } 0055 } 0056 return Encoder::NoEncoder; 0057 } 0058 0059 static void minimizeIfWindowsIntersect(const QRectF &rect) { 0060 if (rect.isEmpty()) { 0061 return; 0062 } 0063 const auto &windows = qGuiApp->allWindows(); 0064 for (auto window : windows) { 0065 if (rect.intersects(window->frameGeometry()) 0066 && window->isVisible() && window->visibility() != QWindow::Minimized) { 0067 if (window->visibility() == QWindow::FullScreen) { 0068 window->setVisible(false); 0069 } 0070 window->showMinimized(); 0071 } 0072 } 0073 } 0074 0075 VideoPlatformWayland::VideoPlatformWayland(QObject *parent) 0076 : VideoPlatform(parent) 0077 , m_screencasting(new Screencasting(this)) 0078 , m_recorder(new PipeWireRecord()) 0079 { 0080 m_recorder->setActive(false); 0081 // m_recorder->setMaxFramerate({30, 1}); 0082 } 0083 0084 VideoPlatform::RecordingModes VideoPlatformWayland::supportedRecordingModes() const 0085 { 0086 if (m_screencasting->isAvailable()) 0087 return Screen | Window | Region; 0088 else 0089 return {}; 0090 } 0091 0092 VideoPlatform::Formats VideoPlatformWayland::supportedFormats() const 0093 { 0094 Formats formats; 0095 if (m_screencasting->isAvailable()) { 0096 const auto encoders = m_recorder->suggestedEncoders(); 0097 for (auto encoder : encoders) { 0098 formats |= formatForEncoder(encoder); 0099 } 0100 } 0101 return formats; 0102 } 0103 0104 static void setWindowInfo(const QVariantMap &data, QRectF &windowRect, bool &isSpectacle) 0105 { 0106 // HACK: Window geometry from queryWindowInfo is from KWin's Window::frameGeometry(), 0107 // which may not be the same as QWindow::frameGeometry() on Wayland. 0108 // Hopefully this is good enough most of the time. 0109 windowRect = { 0110 data[u"x"_s].toDouble(), data[u"y"_s].toDouble(), 0111 data[u"width"_s].toDouble(), data[u"height"_s].toDouble(), 0112 }; 0113 isSpectacle = data[u"desktopFile"_s].toString() == qGuiApp->desktopFileName(); 0114 } 0115 0116 void VideoPlatformWayland::startRecording(const QUrl &fileUrl, RecordingMode recordingMode, const QVariant &option, bool includePointer) 0117 { 0118 if (recordingMode == NoRecordingModes) { 0119 // We should avoid calling startRecording without a recording mode, 0120 // but it shouldn't cause a runtime error if we can handle it gracefully. 0121 Q_EMIT recordingCanceled(u"Recording canceled: No recording mode"_s); 0122 return; 0123 } 0124 if (isRecording()) { 0125 qWarning() << "Warning: Tried to start recording while already recording."; 0126 return; 0127 } 0128 if (!fileUrl.isEmpty() && !fileUrl.isLocalFile()) { 0129 Q_EMIT recordingFailed(i18nc("@info:shell", "Failed to record: File URL is not a local file")); 0130 return; 0131 } 0132 0133 m_recorder->setActive(false); 0134 // BUG: https://bugs.kde.org/show_bug.cgi?id=476964 0135 // CursorMode::Metadata doesn't work. 0136 Screencasting::CursorMode mode = includePointer ? Screencasting::CursorMode::Embedded : Screencasting::Hidden; 0137 ScreencastingStream *stream = nullptr; 0138 switch (recordingMode) { 0139 case Screen: { 0140 auto screen = option.value<QScreen *>(); 0141 if (!screen) { 0142 selectAndRecord(fileUrl, recordingMode, includePointer); 0143 return; 0144 } 0145 Q_ASSERT(screen != nullptr); 0146 minimizeIfWindowsIntersect(screen->geometry()); 0147 stream = m_screencasting->createOutputStream(screen, mode); 0148 break; 0149 } 0150 case Window: { 0151 auto window = option.toString(); 0152 if (window.isEmpty()) { 0153 selectAndRecord(fileUrl, recordingMode, includePointer); 0154 return; 0155 } 0156 Q_ASSERT(!window.isEmpty()); 0157 QDBusMessage message = QDBusMessage::createMethodCall(u"org.kde.KWin"_s, 0158 u"/KWin"_s, 0159 u"org.kde.KWin"_s, 0160 u"getWindowInfo"_s); 0161 message.setArguments({window}); 0162 const QDBusReply<QVariantMap> reply = QDBusConnection::sessionBus().call(message); 0163 const auto &data = reply.value(); 0164 0165 QRectF windowRect; 0166 bool isSpectacle = false; 0167 if (reply.isValid()) { 0168 setWindowInfo(data, windowRect, isSpectacle); 0169 ExportManager::instance()->setWindowTitle(data[u"caption"_s].toString()); 0170 } 0171 0172 if (!windowRect.isEmpty() && !isSpectacle) { 0173 minimizeIfWindowsIntersect(windowRect); 0174 } 0175 stream = m_screencasting->createWindowStream(window, mode); 0176 break; 0177 } 0178 case Region: { 0179 auto region = option.toRect(); 0180 if (region.isEmpty()) { 0181 selectAndRecord(fileUrl, recordingMode, includePointer); 0182 return; 0183 } 0184 qreal scaling = 1; 0185 const auto screens = qGuiApp->screens(); 0186 // Don't make the resolution larger than it needs to be. 0187 for (auto screen : screens) { 0188 if (screen->geometry().intersects(region)) { 0189 scaling = std::max(scaling, screen->devicePixelRatio()); 0190 } 0191 } 0192 stream = m_screencasting->createRegionStream(region, scaling, mode); 0193 break; 0194 } 0195 default: break; // This shouldn't happen 0196 } 0197 0198 Q_ASSERT(stream); 0199 connect(stream, &ScreencastingStream::created, this, [this, stream] { 0200 m_recorder->setNodeId(stream->nodeId()); 0201 if (!m_recorder->output().isEmpty()) { 0202 m_recorder->setActive(true); 0203 } 0204 setRecording(true); 0205 }); 0206 connect(stream, &ScreencastingStream::failed, this, [this](const QString &error) { 0207 setRecording(false); 0208 Q_EMIT recordingFailed(error); 0209 }); 0210 setupOutput(fileUrl); 0211 0212 connect(m_recorder.get(), &PipeWireRecord::stateChanged, this, [this] { 0213 if (m_recorder->state() == PipeWireRecord::Idle && isRecording()) { 0214 setRecording(false); 0215 Q_EMIT recordingSaved(QUrl::fromLocalFile(m_recorder->output())); 0216 } 0217 }); 0218 } 0219 0220 void VideoPlatformWayland::finishRecording() 0221 { 0222 Q_ASSERT(m_recorder); 0223 m_recorder->setActive(false); 0224 } 0225 0226 bool VideoPlatformWayland::mkDirPath(const QUrl &fileUrl) 0227 { 0228 QDir dir(fileUrl.adjusted(QUrl::RemoveFilename).toLocalFile()); 0229 if (dir.exists() || dir.mkpath(u"."_s)) { 0230 return true; 0231 } else { 0232 Q_EMIT recordingFailed(i18nc("@info:shell", "Failed to record: Unable to create folder (%1)", dir.path())); 0233 return false; 0234 } 0235 } 0236 0237 void VideoPlatformWayland::setupOutput(const QUrl &fileUrl) 0238 { 0239 if (!fileUrl.isValid()) { 0240 ExportManager::instance()->updateTimestamp(); 0241 const auto format = static_cast<Format>(Settings::preferredVideoFormat()); 0242 auto tempUrl = ExportManager::instance()->tempVideoUrl(); 0243 if (!tempUrl.isLocalFile()) { 0244 Q_EMIT recordingFailed(i18nc("@info:shell", "Failed to record: Temporary file URL is not a local file (%1)", tempUrl.toString())); 0245 return; 0246 } 0247 if (!mkDirPath(tempUrl)) { 0248 return; 0249 } 0250 m_recorder->setEncoder(encoderForFormat(format)); 0251 m_recorder->setOutput(tempUrl.toLocalFile()); 0252 m_recorder->setActive(m_recorder->nodeId() != 0); 0253 } else { 0254 if (!fileUrl.isLocalFile()) { 0255 Q_EMIT recordingFailed(i18nc("@info:shell", "Failed to record: Output file URL is not a local file (%1)", fileUrl.toString())); 0256 return; 0257 } 0258 if (!mkDirPath(fileUrl)) { 0259 return; 0260 } 0261 const auto &localFile = fileUrl.toLocalFile(); 0262 m_recorder->setEncoder(encoderForFormat(formatForPath(localFile))); 0263 m_recorder->setOutput(localFile); 0264 } 0265 } 0266 0267 void VideoPlatformWayland::selectAndRecord(const QUrl &fileUrl, RecordingMode recordingMode, bool includePointer) 0268 { 0269 if (recordingMode == Region) { 0270 Q_EMIT regionRequested(); 0271 return; 0272 } 0273 0274 // We should probably come up with a better way of choosing outputs. This should be okay for now. #FLW 0275 QDBusMessage message = QDBusMessage::createMethodCall(u"org.kde.KWin"_s, 0276 u"/KWin"_s, 0277 u"org.kde.KWin"_s, 0278 u"queryWindowInfo"_s); 0279 0280 QDBusPendingReply<QVariantMap> asyncReply = QDBusConnection::sessionBus().asyncCall(message); 0281 QDBusPendingCallWatcher *watcher = new QDBusPendingCallWatcher(asyncReply, this); 0282 auto onFinished = [this, fileUrl, recordingMode, includePointer](QDBusPendingCallWatcher *self) { 0283 QDBusPendingReply<QVariantMap> reply = *self; 0284 self->deleteLater(); 0285 if (!reply.isValid()) { 0286 const auto &error = self->error(); 0287 if (error.name() == u"org.kde.KWin.Error.UserCancel"_s) { 0288 QString message; 0289 if (recordingMode == Screen) { 0290 message = i18nc("@info:shell", "Screen recording canceled"); 0291 } else { 0292 message = i18nc("@info:shell", "Window recording canceled"); 0293 } 0294 Q_EMIT recordingCanceled(message); 0295 } else { 0296 QString message; 0297 if (recordingMode == Screen) { 0298 message = i18nc("@info:shell", "Failed to select screen: %1", error.message()); 0299 } else { 0300 message = i18nc("@info:shell", "Failed to select window: %1", error.message()); 0301 } 0302 Q_EMIT recordingFailed(message); 0303 } 0304 return; 0305 } 0306 const auto &data = reply.value(); 0307 QVariant option; 0308 if (recordingMode == Screen) { 0309 QPoint pos = QCursor::pos(); 0310 // if (pos.isNull()) { 0311 // pos = {data[u"x"_s].toInt(), data[u"y"_s].toInt()}; 0312 // } 0313 const auto &screens = qGuiApp->screens(); 0314 QScreen *screen = nullptr; 0315 for (auto s : screens) { 0316 if (s->geometry().contains(pos)) { 0317 screen = s; 0318 break; 0319 } 0320 } 0321 if (!screen) { 0322 Q_EMIT recordingFailed(i18nc("@info:shell", "Failed to select screen: No screen contained the mouse cursor position")); 0323 return; 0324 } 0325 option = QVariant::fromValue(screen); 0326 } else { 0327 const auto &windowId = data.value(u"uuid"_s).toString(); 0328 if (windowId.isEmpty()) { 0329 Q_EMIT recordingFailed(i18nc("@info:shell", "Failed to select window: No window found")); 0330 return; 0331 } 0332 option = windowId; 0333 } 0334 startRecording(fileUrl, recordingMode, option, includePointer); 0335 }; 0336 connect(watcher, &QDBusPendingCallWatcher::finished, this, onFinished); 0337 } 0338 0339 #include "moc_VideoPlatformWayland.cpp"