545 lines
17 KiB
C++
545 lines
17 KiB
C++
#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()));
|
||
}
|