From a83d244199698ec5d8930cd484221e2b4263ca4b Mon Sep 17 00:00:00 2001 From: easyz Date: Sun, 22 Mar 2026 23:44:56 +0800 Subject: [PATCH] feat: add instance activation, OCR self-check, and GIF size presets --- CMakeLists.txt | 2 + qml/Setting/TZGeneralSettings.qml | 166 +++++++++++++++++++++++- qml/main.qml | 12 ++ src/instance_activation.cpp | 63 +++++++++ src/instance_activation.h | 33 +++++ src/main.cpp | 13 +- src/model/app_settings.cpp | 17 +++ src/model/app_settings.h | 5 + src/ocr/ocr_engine.cpp | 66 ++++++++++ src/ocr/ocr_engine.h | 5 + src/viewmodel/gif_record_view_model.cpp | 47 ++++++- src/viewmodel/gif_record_view_model.h | 4 + src/viewmodel/ocr_view_model.cpp | 56 ++++++++ src/viewmodel/ocr_view_model.h | 10 ++ 14 files changed, 486 insertions(+), 13 deletions(-) create mode 100644 src/instance_activation.cpp create mode 100644 src/instance_activation.h diff --git a/CMakeLists.txt b/CMakeLists.txt index a475d3a..f735dd7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,6 +48,8 @@ set(SOURCES src/sticky_image_store.h src/sticky_image_provider_proxy.cpp src/sticky_image_provider_proxy.h + src/instance_activation.cpp + src/instance_activation.h src/singleton.h # Model diff --git a/qml/Setting/TZGeneralSettings.qml b/qml/Setting/TZGeneralSettings.qml index 35c81d6..0b7f510 100644 --- a/qml/Setting/TZGeneralSettings.qml +++ b/qml/Setting/TZGeneralSettings.qml @@ -329,8 +329,172 @@ Rectangle { } } + Item { Layout.preferredHeight: 8 } + + // ── GIF 体积预设 ───────────────────────────── + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 8 + spacing: 8 + Rectangle { + width: 3; height: 15; radius: 2 + color: "#3B82F6" + Layout.alignment: Qt.AlignVCenter + } + Text { + text: qsTr("GIF 录制") + font.pixelSize: 13 + font.bold: true + color: "#1E293B" + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 72 + color: "#FFFFFF" + radius: 10 + border.color: "#E2E8F0" + border.width: 1 + + RowLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + Text { + Layout.preferredWidth: 90 + text: qsTr("体积预设") + font.pixelSize: 13 + color: "#374151" + Layout.alignment: Qt.AlignVCenter + } + + TZComboBox { + id: gifPresetCombo + model: [qsTr("高质量"), qsTr("均衡"), qsTr("小体积")] + currentIndex: O_GifRecordVM.qualityPreset + Layout.preferredWidth: 180 + Layout.preferredHeight: 36 + onCurrentIndexChanged: O_GifRecordVM.setQualityPreset(currentIndex) + } + } + } + + Item { Layout.preferredHeight: 8 } + + // ── OCR 自检 ───────────────────────────────── + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 8 + spacing: 8 + Rectangle { + width: 3; height: 15; radius: 2 + color: "#3B82F6" + Layout.alignment: Qt.AlignVCenter + } + Text { + text: qsTr("OCR 自检") + font.pixelSize: 13 + font.bold: true + color: "#1E293B" + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 168 + color: "#FFFFFF" + radius: 10 + border.color: "#E2E8F0" + border.width: 1 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 10 + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: 8 + color: "#F8FAFC" + border.color: "#E2E8F0" + border.width: 1 + + ScrollView { + anchors.fill: parent + anchors.margins: 8 + clip: true + TextArea { + text: O_OcrVM.selfCheckText.length > 0 + ? O_OcrVM.selfCheckText + : qsTr("点击“一键自检”查看 OCR 环境状态。") + readOnly: true + wrapMode: TextEdit.Wrap + selectByMouse: true + background: null + font.pixelSize: 12 + color: "#334155" + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Rectangle { + Layout.preferredWidth: 100 + Layout.preferredHeight: 34 + radius: 6 + color: selfCheckMouse.containsMouse ? "#2563EB" : "#3B82F6" + Behavior on color { ColorAnimation { duration: 100 } } + Text { + anchors.centerIn: parent + text: qsTr("一键自检") + font.pixelSize: 13 + color: "#FFFFFF" + } + MouseArea { + id: selfCheckMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: O_OcrVM.runSelfCheck() + } + } + + Rectangle { + Layout.preferredWidth: 130 + Layout.preferredHeight: 34 + radius: 6 + color: openTessMouse.containsMouse ? "#E2E8F0" : "#F1F5F9" + border.color: "#CBD5E1" + border.width: 1 + Behavior on color { ColorAnimation { duration: 100 } } + Text { + anchors.centerIn: parent + text: qsTr("打开 tessdata") + font.pixelSize: 13 + color: "#334155" + } + MouseArea { + id: openTessMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (!O_OcrVM.openTessdataFolder()) + O_OcrVM.runSelfCheck() + } + } + } + } + } + } + Item { Layout.fillHeight: true } } } } - diff --git a/qml/main.qml b/qml/main.qml index 4b42316..8402ecb 100644 --- a/qml/main.qml +++ b/qml/main.qml @@ -204,6 +204,18 @@ TZWindow { } } + Connections { + target: O_InstanceBridge + function onActivationRequested() { + if (root.visible) { + root.raise() + root.requestActivate() + return + } + startScreenshot() + } + } + TrayIconHelper { id: trayHelper function onTrayClicked(clickType) { diff --git a/src/instance_activation.cpp b/src/instance_activation.cpp new file mode 100644 index 0000000..eff1a33 --- /dev/null +++ b/src/instance_activation.cpp @@ -0,0 +1,63 @@ +#include "instance_activation.h" + +#include +#include + +InstanceActivation::InstanceActivation(const QString &serverName, QObject *parent) + : QObject(parent) + , m_serverName(serverName) +{ +} + +InstanceActivation::~InstanceActivation() +{ + if (m_server) { + m_server->close(); + m_server->deleteLater(); + m_server = nullptr; + } +} + +bool InstanceActivation::initialize() +{ + // First try to notify an existing instance. + QLocalSocket socket; + socket.connectToServer(m_serverName); + if (socket.waitForConnected(150)) { + socket.write("ACTIVATE"); + socket.flush(); + socket.waitForBytesWritten(150); + socket.disconnectFromServer(); + return false; + } + + // Become the primary instance. + QLocalServer::removeServer(m_serverName); + m_server = new QLocalServer(this); + connect(m_server, &QLocalServer::newConnection, + this, &InstanceActivation::onNewConnection); + if (!m_server->listen(m_serverName)) { + return true; + } + return true; +} + +void InstanceActivation::onNewConnection() +{ + if (!m_server) { + return; + } + while (m_server->hasPendingConnections()) { + QLocalSocket *client = m_server->nextPendingConnection(); + if (!client) { + continue; + } + connect(client, &QLocalSocket::readyRead, client, [this, client]() { + client->readAll(); + emit activationRequested(); + client->disconnectFromServer(); + client->deleteLater(); + }); + connect(client, &QLocalSocket::disconnected, client, &QLocalSocket::deleteLater); + } +} diff --git a/src/instance_activation.h b/src/instance_activation.h new file mode 100644 index 0000000..5756063 --- /dev/null +++ b/src/instance_activation.h @@ -0,0 +1,33 @@ +#ifndef INSTANCE_ACTIVATION_H +#define INSTANCE_ACTIVATION_H + +#include +#include + +class QLocalServer; +class QLocalSocket; + +class InstanceActivation : public QObject +{ + Q_OBJECT + +public: + explicit InstanceActivation(const QString &serverName, QObject *parent = nullptr); + ~InstanceActivation() override; + + // Returns true if this process should continue startup. + // Returns false when another instance is already running (activation message sent). + bool initialize(); + +signals: + void activationRequested(); + +private slots: + void onNewConnection(); + +private: + QString m_serverName; + QLocalServer *m_server = nullptr; +}; + +#endif // INSTANCE_ACTIVATION_H diff --git a/src/main.cpp b/src/main.cpp index 86011ec..674d9ac 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,9 +1,6 @@ #include #include #include -#include -#include -#include #include "tray_icon_helper.h" #include "paint_board/paint_board.h" @@ -12,6 +9,7 @@ #include "sticky_image_store.h" #include "sticky_image_provider_proxy.h" #include "language_manager.h" +#include "instance_activation.h" // Model #include "model/desktop_snapshot.h" @@ -29,11 +27,9 @@ int main(int argc, char *argv[]) { QApplication app(argc, argv); - // Single-instance guard: allow only one TZshot process at a time. - const QString lockPath = QDir(QStandardPaths::writableLocation(QStandardPaths::TempLocation)) - .filePath("TZshot.instance.lock"); - QLockFile instanceLock(lockPath); - if (!instanceLock.tryLock(0)) { + // Single-instance activation: second launch notifies the first one and exits. + InstanceActivation instanceActivation(QStringLiteral("TZshot.Instance")); + if (!instanceActivation.initialize()) { return 0; } @@ -83,6 +79,7 @@ int main(int argc, char *argv[]) engine.rootContext()->setContextProperty("O_LanguageManager", &languageManager); engine.rootContext()->setContextProperty("O_GifRecordVM", &gifRecordViewModel); engine.rootContext()->setContextProperty("O_OcrVM", &ocrViewModel); + engine.rootContext()->setContextProperty("O_InstanceBridge", &instanceActivation); const QUrl url(QStringLiteral("qrc:/qml/main.qml")); QObject::connect(&engine, &QQmlApplicationEngine::objectCreated, diff --git a/src/model/app_settings.cpp b/src/model/app_settings.cpp index 9552c3f..9c1c7b0 100644 --- a/src/model/app_settings.cpp +++ b/src/model/app_settings.cpp @@ -5,6 +5,7 @@ #include #include #include +#include AppSettings::AppSettings() { @@ -40,6 +41,9 @@ AppSettings::AppSettings() } } qInfo() << "[AppSettings] 保存路径:" << m_savePath; + + const int savedPreset = s.value("GIF/qualityPreset", 1).toInt(); + m_gifQualityPreset = qBound(0, savedPreset, 2); } // ── AI 配置 ─────────────────────────────────────────────── @@ -100,3 +104,16 @@ void AppSettings::setSavePath(const QString &path) QSettings().setValue("ImageSaver/savePath", m_savePath); } + +int AppSettings::gifQualityPreset() const +{ + return m_gifQualityPreset; +} + +void AppSettings::setGifQualityPreset(int preset) +{ + const int normalized = qBound(0, preset, 2); + if (m_gifQualityPreset == normalized) return; + m_gifQualityPreset = normalized; + QSettings().setValue("GIF/qualityPreset", m_gifQualityPreset); +} diff --git a/src/model/app_settings.h b/src/model/app_settings.h index 461e47a..88db133 100644 --- a/src/model/app_settings.h +++ b/src/model/app_settings.h @@ -30,11 +30,16 @@ public: QString savePath() const; void setSavePath(const QString &path); + // GIF 体积质量预设:0=高质量,1=均衡,2=小体积 + int gifQualityPreset() const; + void setGifQualityPreset(int preset); + private: QString m_apiKey; int m_selectedModel = 0; QString m_language = "zh_CN"; QString m_savePath; + int m_gifQualityPreset = 1; }; #endif // APP_SETTINGS_H diff --git a/src/ocr/ocr_engine.cpp b/src/ocr/ocr_engine.cpp index 4122e65..d88457d 100644 --- a/src/ocr/ocr_engine.cpp +++ b/src/ocr/ocr_engine.cpp @@ -29,6 +29,9 @@ QStringList resolveTessdataDirs() #ifdef OCR_TESSDATA_DIR QStringLiteral(OCR_TESSDATA_DIR), #endif + appDir + "/thirdpart/ocr-install/share/tessdata", + appDir + "/../thirdpart/ocr-install/share/tessdata", + appDir + "/../../thirdpart/ocr-install/share/tessdata", appDir + "/thirdpart/ocr-win/share/tessdata", appDir + "/../thirdpart/ocr-win/share/tessdata", appDir + "/../../thirdpart/ocr-win/share/tessdata", @@ -59,6 +62,46 @@ QStringList resolveTessdataDirs() return validDirs; } +QStringList resolveTessdataCandidates() +{ + const QString appDir = QCoreApplication::applicationDirPath(); + QStringList candidates = { +#ifdef OCR_TESSDATA_DIR + QStringLiteral(OCR_TESSDATA_DIR), +#endif + appDir + "/thirdpart/ocr-install/share/tessdata", + appDir + "/../thirdpart/ocr-install/share/tessdata", + appDir + "/../../thirdpart/ocr-install/share/tessdata", + appDir + "/thirdpart/ocr-win/share/tessdata", + appDir + "/../thirdpart/ocr-win/share/tessdata", + appDir + "/../../thirdpart/ocr-win/share/tessdata", + appDir + "/thirdpart/ocr/share/tessdata", + appDir + "/../thirdpart/ocr/share/tessdata", + appDir + "/../../thirdpart/ocr/share/tessdata", + QStringLiteral("/usr/share/tesseract-ocr/5/tessdata"), + QStringLiteral("/usr/share/tesseract-ocr/4.00/tessdata") + }; + + const QByteArray envPrefix = qgetenv("TESSDATA_PREFIX"); + if (!envPrefix.isEmpty()) { + const QString envPath = QString::fromUtf8(envPrefix); + if (QFileInfo(envPath).fileName() == QStringLiteral("tessdata")) { + candidates.prepend(envPath); + } else { + candidates.prepend(envPath + "/tessdata"); + } + } + + QStringList normalized; + for (const QString &candidate : candidates) { + const QString path = QDir(candidate).absolutePath(); + if (!normalized.contains(path)) { + normalized << path; + } + } + return normalized; +} + QString resolveTesseractProgram() { const QString appDir = QCoreApplication::applicationDirPath(); @@ -115,6 +158,29 @@ QString resolveTesseractProgram() } } // namespace +QStringList OcrEngine::tessdataSearchCandidates() +{ + return resolveTessdataCandidates(); +} + +QStringList OcrEngine::availableTessdataDirs() +{ + return resolveTessdataDirs(); +} + +QString OcrEngine::suggestedTessdataDir() +{ + const QStringList available = availableTessdataDirs(); + if (!available.isEmpty()) { + return available.first(); + } + const QStringList candidates = tessdataSearchCandidates(); + if (!candidates.isEmpty()) { + return candidates.first(); + } + return {}; +} + OcrEngine::OcrEngine() { #ifdef ENABLE_OCR diff --git a/src/ocr/ocr_engine.h b/src/ocr/ocr_engine.h index 3a6e8fb..0f094c7 100644 --- a/src/ocr/ocr_engine.h +++ b/src/ocr/ocr_engine.h @@ -21,6 +21,11 @@ public: QString lastError() const { return m_lastError; } bool isReady() const { return m_ready; } + // Diagnostics helpers for UI self-check. + static QStringList tessdataSearchCandidates(); + static QStringList availableTessdataDirs(); + static QString suggestedTessdataDir(); + private: tesseract::TessBaseAPI *m_api = nullptr; bool m_ready = false; diff --git a/src/viewmodel/gif_record_view_model.cpp b/src/viewmodel/gif_record_view_model.cpp index 1945d79..e74ef1d 100644 --- a/src/viewmodel/gif_record_view_model.cpp +++ b/src/viewmodel/gif_record_view_model.cpp @@ -10,6 +10,7 @@ #include #include #include +#include GifRecordViewModel::GifRecordViewModel(AppSettings& settings, QObject* parent) : QObject(parent) @@ -108,6 +109,16 @@ void GifRecordViewModel::cancelRecording() qDebug() << "[GifRecord] 取消录制"; } +void GifRecordViewModel::setQualityPreset(int preset) +{ + const int normalized = qBound(0, preset, 2); + if (m_settings.gifQualityPreset() == normalized) { + return; + } + m_settings.setGifQualityPreset(normalized); + emit qualityPresetChanged(); +} + void GifRecordViewModel::onFrameCaptured(const QImage& frame) { if (!m_recording) return; @@ -162,13 +173,41 @@ void GifRecordViewModel::startEncoding() qDebug() << "[GifRecord] 开始编码 →" << m_pendingSavePath; - // 捕获当前帧序列的副本(子线程持有) - QVector framesToEncode = m_frames; + // 根据质量预设做抽帧/缩放,平衡体积与清晰度。 + const int preset = m_settings.gifQualityPreset(); + int frameStep = 1; + qreal scale = 1.0; + int encodeFps = kFps; + if (preset == 1) { // 均衡 + frameStep = 2; + scale = 0.85; + encodeFps = 12; + } else if (preset == 2) { // 小体积 + frameStep = 3; + scale = 0.7; + encodeFps = 10; + } + + QVector framesToEncode; + framesToEncode.reserve((m_frames.size() + frameStep - 1) / frameStep); + for (int i = 0; i < m_frames.size(); i += frameStep) { + QImage frame = m_frames[i]; + if (scale < 0.999) { + const int dstW = qMax(2, int(frame.width() * scale)); + const int dstH = qMax(2, int(frame.height() * scale)); + frame = frame.scaled(dstW, dstH, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + } + framesToEncode.append(frame); + } + if (framesToEncode.isEmpty() && !m_frames.isEmpty()) { + framesToEncode.append(m_frames.first()); + } + QString savePath = m_pendingSavePath; - QFuture future = QtConcurrent::run([framesToEncode, savePath]() -> bool { + QFuture future = QtConcurrent::run([framesToEncode, savePath, encodeFps]() -> bool { GifEncoder encoder; - bool ok = encoder.encode(framesToEncode, savePath, GifRecordViewModel::kFps, true); + bool ok = encoder.encode(framesToEncode, savePath, encodeFps, true); if (!ok) { qDebug() << "[GifRecord] 编码错误:" << encoder.errorString(); } diff --git a/src/viewmodel/gif_record_view_model.h b/src/viewmodel/gif_record_view_model.h index 08c0855..b714719 100644 --- a/src/viewmodel/gif_record_view_model.h +++ b/src/viewmodel/gif_record_view_model.h @@ -36,6 +36,7 @@ class GifRecordViewModel : public QObject Q_PROPERTY(bool isEncoding READ isEncoding NOTIFY isEncodingChanged) Q_PROPERTY(int frameCount READ frameCount NOTIFY frameCountChanged) Q_PROPERTY(int elapsedSecs READ elapsedSecs NOTIFY elapsedSecsChanged) + Q_PROPERTY(int qualityPreset READ qualityPreset WRITE setQualityPreset NOTIFY qualityPresetChanged) public: explicit GifRecordViewModel(AppSettings& settings, QObject* parent = nullptr); @@ -45,6 +46,7 @@ public: bool isEncoding() const { return m_encoding; } int frameCount() const { return m_frames.size(); } int elapsedSecs() const { return m_elapsedSecs; } + int qualityPreset() const { return m_settings.gifQualityPreset(); } // QML 调用:开始录制,captureRect 为屏幕逻辑坐标选区 Q_INVOKABLE void startRecording(int x, int y, int w, int h); @@ -57,6 +59,7 @@ public: // QML 调用:取消录制,丢弃所有帧 Q_INVOKABLE void cancelRecording(); + Q_INVOKABLE void setQualityPreset(int preset); // 最大录制帧数(防止内存过大),默认 15fps × 30s = 450 帧 static constexpr int kMaxFrames = 450; @@ -66,6 +69,7 @@ signals: void isEncodingChanged(); void frameCountChanged(); void elapsedSecsChanged(); + void qualityPresetChanged(); void encodingFinished(const QString& savedPath); void encodingFailed(const QString& error); diff --git a/src/viewmodel/ocr_view_model.cpp b/src/viewmodel/ocr_view_model.cpp index 3d6df33..d40e1e5 100644 --- a/src/viewmodel/ocr_view_model.cpp +++ b/src/viewmodel/ocr_view_model.cpp @@ -5,6 +5,10 @@ #include #include #include +#include +#include +#include +#include // ── 构造 ───────────────────────────────────────────────── OcrViewModel::OcrViewModel(QObject *parent) @@ -76,3 +80,55 @@ void OcrViewModel::copyResultToClipboard() #endif } } + +QString OcrViewModel::runSelfCheck() +{ + const QStringList available = OcrEngine::availableTessdataDirs(); + const QStringList candidates = OcrEngine::tessdataSearchCandidates(); + + QString report; + if (!available.isEmpty()) { + report += tr("OCR 环境检查:通过\n"); + report += tr("可用 tessdata:\n"); + for (const QString &dir : available) { + report += QStringLiteral(" - %1\n").arg(QDir::toNativeSeparators(dir)); + } + } else { + report += tr("OCR 环境检查:未通过(缺少 chi_sim/eng 语言包)\n"); + report += tr("建议把以下文件放到目录中:\n"); + report += QStringLiteral(" - chi_sim.traineddata\n"); + report += QStringLiteral(" - eng.traineddata\n"); + report += tr("候选 tessdata 目录:\n"); + for (const QString &dir : candidates) { + report += QStringLiteral(" - %1\n").arg(QDir::toNativeSeparators(dir)); + } + } + + OcrEngine engine; + if (engine.isReady()) { + report += tr("引擎初始化:成功"); + } else { + report += tr("引擎初始化:失败\n"); + report += tr("错误信息:%1").arg(engine.lastError()); + } + + m_selfCheckText = report.trimmed(); + emit selfCheckTextChanged(); + return m_selfCheckText; +} + +bool OcrViewModel::openTessdataFolder() +{ + QString dirPath = OcrEngine::suggestedTessdataDir(); + if (dirPath.isEmpty()) { + return false; + } + + QDir dir(dirPath); + if (!dir.exists()) { + if (!QDir().mkpath(dirPath)) { + return false; + } + } + return QDesktopServices::openUrl(QUrl::fromLocalFile(QFileInfo(dirPath).absoluteFilePath())); +} diff --git a/src/viewmodel/ocr_view_model.h b/src/viewmodel/ocr_view_model.h index 3330e44..369c37d 100644 --- a/src/viewmodel/ocr_view_model.h +++ b/src/viewmodel/ocr_view_model.h @@ -17,6 +17,7 @@ class OcrViewModel : public QObject Q_PROPERTY(bool isRecognizing READ isRecognizing NOTIFY isRecognizingChanged) Q_PROPERTY(QString resultText READ resultText NOTIFY resultTextChanged) + Q_PROPERTY(QString selfCheckText READ selfCheckText NOTIFY selfCheckTextChanged) public: explicit OcrViewModel(QObject *parent = nullptr); @@ -24,6 +25,7 @@ public: bool isRecognizing() const { return m_isRecognizing; } QString resultText() const { return m_resultText; } + QString selfCheckText() const { return m_selfCheckText; } // 提交识别任务(供 QML 调用) Q_INVOKABLE void recognize(const QImage &image); @@ -31,9 +33,16 @@ public: // 将结果文本复制到系统剪贴板 Q_INVOKABLE void copyResultToClipboard(); + // OCR 环境一键自检(返回文本可直接展示到 UI) + Q_INVOKABLE QString runSelfCheck(); + + // 打开 tessdata 目录(若目录不存在会尝试创建) + Q_INVOKABLE bool openTessdataFolder(); + signals: void isRecognizingChanged(); void resultTextChanged(); + void selfCheckTextChanged(); void resultReady(const QString &text); void recognizeFailed(const QString &errorMessage); @@ -43,6 +52,7 @@ private slots: private: bool m_isRecognizing = false; QString m_resultText; + QString m_selfCheckText; QFutureWatcher m_watcher; };