Files
uartscope/src/videowidget.cpp
2026-09-15 20:21:22 +02:00

545 lines
17 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "videowidget.h"
#include <QPainter>
#include <QFileDialog>
#include <QFileInfo>
#include <QDir>
#include <QDateTime>
#include <QStandardPaths>
#include <QMetaObject>
#include <QResizeEvent>
#include <QMessageBox>
#include <QKeyEvent>
#include <QMouseEvent>
#include <QCloseEvent>
#include <QScreen>
// ── 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<int, QProcess::ExitStatus>::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<const char *>(bgra.constBits()),
static_cast<qint64>(bgra.sizeInBytes()));
}