feat: add instance activation, OCR self-check, and GIF size presets

This commit is contained in:
easyz committed 2026-03-22 23:44:56 +08:00
1 parent 57b4e8b207
commit a83d244199
14 files changed
+486 -13

No files matched your search

+2
View File
@@ -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
+165 -1
View File
@@ -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 }
}
}
}
+12
View File
@@ -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) {
+63
View File
@@ -0,0 +1,63 @@
#include "instance_activation.h"
#include <QLocalServer>
#include <QLocalSocket>
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);
}
}
+33
View File
@@ -0,0 +1,33 @@
#ifndef INSTANCE_ACTIVATION_H
#define INSTANCE_ACTIVATION_H
#include <QObject>
#include <QString>
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
+5 -8
View File
@@ -1,9 +1,6 @@
#include <QApplication>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QLockFile>
#include <QStandardPaths>
#include <QDir>
#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,
+17
View File
@@ -5,6 +5,7 @@
#include <QDir>
#include <QDebug>
#include <QLocale>
#include <QtGlobal>
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);
}
+5
View File
@@ -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
+66
View File
@@ -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
+5
View File
@@ -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;
+43 -4
View File
@@ -10,6 +10,7 @@
#include <QDesktopServices>
#include <QFileInfo>
#include <QUrl>
#include <QtGlobal>
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<QImage> 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<QImage> 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<bool> future = QtConcurrent::run([framesToEncode, savePath]() -> bool {
QFuture<bool> 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();
}
+4
View File
@@ -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);
+56
View File
@@ -5,6 +5,10 @@
#include <QClipboard>
#include <QGuiApplication>
#include <QDebug>
#include <QDesktopServices>
#include <QDir>
#include <QFileInfo>
#include <QUrl>
// ── 构造 ─────────────────────────────────────────────────
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()));
}
+10
View File
@@ -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<QString> m_watcher;
};