#include "videowidget.h" #include #include #include #include #include #include #include #include #include #include #include #include #include // ── FullscreenVideoView ──────────────────────────────────────────────── FullscreenVideoView::FullscreenVideoView(QWidget *parent) : QWidget(parent, Qt::Window) { setWindowTitle(tr("uartscope - Video (Fullscreen)")); setAttribute(Qt::WA_DeleteOnClose); setCursor(Qt::BlankCursor); QPalette pal = palette(); pal.setColor(QPalette::Window, Qt::black); setPalette(pal); setAutoFillBackground(true); } void FullscreenVideoView::setFrame(const QImage &frame) { m_frame = frame; update(); } void FullscreenVideoView::paintEvent(QPaintEvent *) { QPainter p(this); p.fillRect(rect(), Qt::black); if (m_frame.isNull()) { p.setPen(QColor(0x44, 0x44, 0x44)); p.setFont(QFont("Sans", 14)); p.drawText(rect(), Qt::AlignCenter, tr("No signal")); return; } const QImage scaled = m_frame.scaled(size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); const QPoint origin((width() - scaled.width()) / 2, (height() - scaled.height()) / 2); p.drawImage(origin, scaled); } void FullscreenVideoView::keyPressEvent(QKeyEvent *event) { if (event->key() == Qt::Key_Escape || event->key() == Qt::Key_F11) close(); else QWidget::keyPressEvent(event); } void FullscreenVideoView::mouseDoubleClickEvent(QMouseEvent *) { close(); } void FullscreenVideoView::closeEvent(QCloseEvent *event) { emit closed(); QWidget::closeEvent(event); } // ── VideoWidget ───────────────────────────────────────────────────────── static QStringList enumerateVideoDevices() { QStringList devices; const QDir dev("/dev"); const auto entries = dev.entryList({"video*"}, QDir::System, QDir::Name); for (const auto &e : entries) devices << "/dev/" + e; return devices; } VideoWidget::VideoWidget(QWidget *parent) : QWidget(parent) { setupUi(); m_thread = new QThread(this); m_worker = new V4l2Worker(); m_worker->moveToThread(m_thread); connect(m_thread, &QThread::finished, m_worker, &QObject::deleteLater); connect(m_worker, &V4l2Worker::newFrame, this, &VideoWidget::onNewFrame, Qt::QueuedConnection); connect(m_worker, &V4l2Worker::captureStarted, this, [this](const QString &dev, int w, int h, const QString &fmt) { m_infoLabel->setText(tr("%1 %2×%3 %4").arg(dev).arg(w).arg(h).arg(fmt)); m_running = true; m_startBtn->setText(tr("■ Stop")); }); connect(m_worker, &V4l2Worker::captureStopped, this, [this] { m_running = false; m_startBtn->setText(tr("▶ Start")); m_infoLabel->setText(tr("Stopped")); if (m_recording) stopRecording(); if (m_fullscreenView) m_fullscreenView->close(); update(); }); connect(m_worker, &V4l2Worker::errorOccurred, this, [this](const QString &msg) { m_infoLabel->setText(tr("Error: %1").arg(msg)); m_running = false; m_startBtn->setText(tr("▶ Start")); if (m_recording) stopRecording(); }); m_thread->start(); } void VideoWidget::shutdown() { if (m_shutdownDone) return; m_shutdownDone = true; if (m_fullscreenView) m_fullscreenView->close(); if (m_recording) stopRecording(); m_worker->stopCapture(); if (m_thread && m_thread->isRunning()) { m_thread->quit(); m_thread->wait(5000); if (m_thread->isRunning()) m_thread->terminate(); } } VideoWidget::~VideoWidget() { shutdown(); } void VideoWidget::setupUi() { setMinimumHeight(140); auto *layout = new QVBoxLayout(this); layout->setContentsMargins(2, 2, 2, 2); layout->setSpacing(3); auto *ctrlRow = new QHBoxLayout(); ctrlRow->setSpacing(4); m_deviceCombo = new QComboBox(this); m_deviceCombo->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); refreshDeviceList(); auto *refreshBtn = new QPushButton("↻", this); refreshBtn->setMaximumWidth(26); connect(refreshBtn, &QPushButton::clicked, this, &VideoWidget::refreshDeviceList); m_startBtn = new QPushButton(tr("▶ Start"), this); m_startBtn->setMaximumWidth(70); connect(m_startBtn, &QPushButton::clicked, this, &VideoWidget::onStartStop); m_freezeBtn = new QPushButton(tr("❚❚ Freeze"), this); m_freezeBtn->setCheckable(true); m_freezeBtn->setMaximumWidth(80); connect(m_freezeBtn, &QPushButton::toggled, this, &VideoWidget::onFreeze); m_screenshotBtn = new QPushButton(tr("📷"), this); m_screenshotBtn->setMaximumWidth(32); connect(m_screenshotBtn, &QPushButton::clicked, this, &VideoWidget::onScreenshot); m_recordBtn = new QPushButton(tr("⏺ Record"), this); m_recordBtn->setCheckable(true); m_recordBtn->setMaximumWidth(80); m_recordBtn->setToolTip(tr("Record the live preview to an MP4 file (requires ffmpeg)")); connect(m_recordBtn, &QPushButton::toggled, this, &VideoWidget::onRecordToggled); m_fullscreenBtn = new QPushButton(tr("⛶"), this); m_fullscreenBtn->setMaximumWidth(28); m_fullscreenBtn->setToolTip(tr("Show video fullscreen (Esc or double-click to exit)")); connect(m_fullscreenBtn, &QPushButton::clicked, this, &VideoWidget::onFullscreenToggle); ctrlRow->addWidget(m_deviceCombo, 1); ctrlRow->addWidget(refreshBtn); ctrlRow->addWidget(m_startBtn); ctrlRow->addWidget(m_freezeBtn); ctrlRow->addWidget(m_screenshotBtn); ctrlRow->addWidget(m_recordBtn); ctrlRow->addWidget(m_fullscreenBtn); layout->addLayout(ctrlRow); m_infoLabel = new QLabel(tr("No device started"), this); m_infoLabel->setStyleSheet("color: gray; font-size: 9px;"); layout->addWidget(m_infoLabel); layout->addStretch(1); } void VideoWidget::onStartStop() { if (m_running) stopCapture(); else startCapture(); } void VideoWidget::onFreeze(bool frozen) { m_frozen = frozen; m_freezeBtn->setText(frozen ? tr("▶ Live") : tr("❚❚ Freeze")); } void VideoWidget::onScreenshot() { if (m_frame.isNull()) return; const QString defaultName = QStandardPaths::writableLocation(QStandardPaths::PicturesLocation) + "/uartscope_" + QDateTime::currentDateTime().toString("yyyyMMdd_hhmmss") + ".png"; const QString path = QFileDialog::getSaveFileName( this, tr("Save screenshot"), defaultName, tr("PNG Image (*.png);;JPEG Image (*.jpg *.jpeg)")); if (!path.isEmpty()) m_frame.save(path); } void VideoWidget::saveAutoScreenshot(const QString &filename) { if (m_frame.isNull()) { emit autoScreenshotResult(false, tr("[SCREENSHOT] tag received, but no video frame is available yet.")); return; } if (m_autoScreenshotDir.isEmpty()) { emit autoScreenshotResult(false, tr("[SCREENSHOT] tag received, but no auto-screenshot folder is " "configured (toolbar → \"Screenshot folder…\").")); return; } // Only take the bare filename from what the firmware sent - this // strips any directory components (incl. attempts at '../' path // traversal), so the firmware can never dictate where on disk we // write. Fall back to a timestamp if it sent nothing usable, and // default to .png if it didn't specify an extension. QString name = QFileInfo(filename.trimmed()).fileName(); if (name.isEmpty()) { name = QStringLiteral("screenshot_%1.png") .arg(QDateTime::currentDateTime().toString("yyyyMMdd_hhmmss_zzz")); } else if (QFileInfo(name).suffix().isEmpty()) { name += QStringLiteral(".png"); } const QDir dir(m_autoScreenshotDir); QString path = dir.filePath(name); // Don't silently overwrite an earlier capture if the firmware reuses // the same filename on every trigger (e.g. a generic "dump.png") - // append a numeric suffix instead so nothing gets lost. if (QFileInfo::exists(path)) { const QString base = QFileInfo(name).completeBaseName(); const QString suffix = QFileInfo(name).suffix(); int n = 1; do { path = dir.filePath(QStringLiteral("%1_%2.%3").arg(base).arg(n).arg(suffix)); ++n; } while (QFileInfo::exists(path)); } if (m_frame.save(path)) emit autoScreenshotResult(true, tr("Screenshot saved: %1").arg(path)); else emit autoScreenshotResult(false, tr("Failed to save screenshot to %1").arg(path)); } void VideoWidget::onNewFrame(const QImage &frame) { // Feed the recorder regardless of freeze state, so pausing the preview // doesn't also pause/corrupt the recording - recording tracks the real // incoming stream, not what's currently shown on screen. if (m_recording && m_recordProcess && m_recordProcess->state() == QProcess::Running) { writeRecordingFrame(frame); } if (m_frozen) return; m_frame = frame; updateScaled(); update(); if (m_fullscreenView) m_fullscreenView->setFrame(frame); } void VideoWidget::clearFrame() { m_frame = QImage(); m_scaled = QImage(); update(); } void VideoWidget::refreshDeviceList() { const QString current = m_deviceCombo->currentText(); m_deviceCombo->clear(); const auto devices = enumerateVideoDevices(); for (const auto &d : devices) m_deviceCombo->addItem(d); const int idx = m_deviceCombo->findText(current); if (idx >= 0) m_deviceCombo->setCurrentIndex(idx); } void VideoWidget::startCapture() { const QString dev = m_deviceCombo->currentText(); if (dev.isEmpty()) return; m_frame = QImage(); m_scaled = QImage(); QMetaObject::invokeMethod(m_worker, "startCapture", Qt::QueuedConnection, Q_ARG(QString, dev), Q_ARG(int, 1920), Q_ARG(int, 1080)); } void VideoWidget::stopCapture() { QMetaObject::invokeMethod(m_worker, "stopCapture", Qt::QueuedConnection); } void VideoWidget::updateScaled() { const QRect videoRect(0, 55, width(), height() - 55); if (videoRect.width() < 10 || videoRect.height() < 10 || m_frame.isNull()) return; m_scaled = m_frame.scaled(videoRect.size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); } void VideoWidget::paintEvent(QPaintEvent *) { QPainter p(this); p.fillRect(rect(), QColor(0x10, 0x10, 0x10)); if (m_scaled.isNull()) { p.setPen(QColor(0x44, 0x44, 0x44)); p.drawText(rect(), Qt::AlignCenter, m_running ? tr("Waiting for frames…") : tr("No signal")); return; } const QRect videoRect(0, 55, width(), height() - 55); const QPoint origin( videoRect.left() + (videoRect.width() - m_scaled.width()) / 2, videoRect.top() + (videoRect.height() - m_scaled.height()) / 2); p.drawImage(origin, m_scaled); if (m_frozen) { p.setPen(QColor(0xff, 0xcc, 0x00)); p.setFont(QFont("Sans", 9, QFont::Bold)); p.drawText(videoRect.adjusted(4, 4, -4, -4), Qt::AlignTop | Qt::AlignRight, tr("FREEZE")); } } void VideoWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); updateScaled(); } // ── Recording (raw frames piped into ffmpeg, encoded to MP4) ────────────── void VideoWidget::onRecordToggled(bool checked) { if (checked) startRecording(); else stopRecording(); } void VideoWidget::onFullscreenToggle() { // Already open - a second click just closes it again. if (m_fullscreenView) { m_fullscreenView->close(); return; } m_fullscreenView = new FullscreenVideoView(); connect(m_fullscreenView, &FullscreenVideoView::closed, this, [this] { m_fullscreenView = nullptr; }); // Put it on whichever screen the main window currently lives on // (useful when a second/HDMI display is attached), then go fullscreen. if (QScreen *screen = this->screen()) m_fullscreenView->setGeometry(screen->geometry()); m_fullscreenView->setFrame(m_frame); m_fullscreenView->showFullScreen(); } void VideoWidget::startRecording() { if (m_frame.isNull()) { QMessageBox::warning(this, tr("No signal"), tr("Start the video capture first before recording.")); m_recordBtn->setChecked(false); return; } const QString defaultName = QStandardPaths::writableLocation(QStandardPaths::MoviesLocation) + "/uartscope_" + QDateTime::currentDateTime().toString("yyyyMMdd_hhmmss") + ".mp4"; const QString path = QFileDialog::getSaveFileName( this, tr("Save recording as"), defaultName, tr("MP4 Video (*.mp4)")); if (path.isEmpty()) { m_recordBtn->setChecked(false); return; } // Frames are piped in as raw BGRA (matching QImage::Format_RGB32's // in-memory byte layout on little-endian systems) at whatever // resolution capture is currently running at; a resolution change // mid-recording would corrupt ffmpeg's raw input stream, so // writeRecordingFrame() watches for that and stops the recording if // it happens (see there). m_recordSize = m_frame.size(); m_recordProcess = new QProcess(this); connect(m_recordProcess, &QProcess::errorOccurred, this, [this](QProcess::ProcessError) { m_infoLabel->setText(tr("Recording error: %1").arg(m_recordProcess->errorString())); stopRecording(); }); connect(m_recordProcess, QOverload::of(&QProcess::finished), this, [this](int exitCode, QProcess::ExitStatus) { if (exitCode != 0 && m_recordProcess) m_infoLabel->setText(tr("ffmpeg exited with code %1").arg(exitCode)); if (m_recordProcess) { m_recordProcess->deleteLater(); m_recordProcess = nullptr; } }); // -use_wallclock_as_timestamps: frames don't arrive at a strictly // constant rate (V4L2 delivers whatever the device/driver produces), // so we let ffmpeg timestamp each frame using the system clock at the // moment it's read from the pipe rather than assuming a fixed // framerate. That keeps the resulting MP4's real-time duration // correct even if the capture rate varies or briefly stalls. const QStringList args = { "-y", "-f", "rawvideo", "-pixel_format", "bgra", "-video_size", QStringLiteral("%1x%2").arg(m_recordSize.width()).arg(m_recordSize.height()), "-use_wallclock_as_timestamps", "1", "-i", "-", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "veryfast", "-movflags", "+faststart", path }; m_recordProcess->start("ffmpeg", args); if (!m_recordProcess->waitForStarted(3000)) { QMessageBox::critical(this, tr("Recording failed"), tr("Could not start ffmpeg. Is it installed and available on PATH?")); m_recordProcess->deleteLater(); m_recordProcess = nullptr; m_recordBtn->setChecked(false); return; } m_recording = true; m_recordBtn->setText(tr("⏹ Stop")); m_infoLabel->setText(tr("Recording to %1").arg(path)); } void VideoWidget::stopRecording() { m_recording = false; m_recordBtn->setChecked(false); m_recordBtn->setText(tr("⏺ Record")); if (m_recordProcess) { // Closing stdin signals ffmpeg there's no more input; it then // finishes encoding/muxing and exits on its own. The finished() // handler connected in startRecording() cleans the QProcess up // once that happens. m_recordProcess->closeWriteChannel(); if (!m_recordProcess->waitForFinished(3000)) m_recordProcess->terminate(); } } void VideoWidget::writeRecordingFrame(const QImage &frame) { if (frame.size() != m_recordSize) { // The capture device changed resolution mid-recording (shouldn't // normally happen, but e.g. a device re-negotiating format could // trigger it). Feeding ffmpeg mismatched frame sizes on a raw pipe // would desync/corrupt the output, so stop cleanly instead. stopRecording(); m_infoLabel->setText(tr("Recording stopped: frame size changed")); return; } // Format_RGB32 stores each pixel as 0xffRRGGBB in host byte order, // which on little-endian systems (the overwhelming majority of // desktop/embedded targets) is byte-for-byte BGRA - matching the // "-pixel_format bgra" we told ffmpeg to expect above. const QImage bgra = frame.convertToFormat(QImage::Format_RGB32); m_recordProcess->write(reinterpret_cast(bgra.constBits()), static_cast(bgra.sizeInBytes())); }