feat: add PixPin-style long screenshot with auto scroll-triggered stitching

This commit is contained in:
easyz committed 2026-03-23 20:20:52 +08:00
1 parent a83d244199
commit 0de4914d0b
17 files changed
+908 -19

No files matched your search

+16 -6
View File
@@ -69,6 +69,8 @@ set(SOURCES
src/viewmodel/storage_view_model.h
src/viewmodel/gif_record_view_model.cpp
src/viewmodel/gif_record_view_model.h
src/viewmodel/scroll_capture_view_model.cpp
src/viewmodel/scroll_capture_view_model.h
src/viewmodel/ocr_view_model.cpp
src/viewmodel/ocr_view_model.h
@@ -78,6 +80,8 @@ set(SOURCES
src/gif_encoder/gif_encoder.h
src/gif_encoder/gif_frame_grabber.cpp
src/gif_encoder/gif_frame_grabber.h
src/scroll/scroll_stitcher.cpp
src/scroll/scroll_stitcher.h
# OCR
src/ocr/ocr_engine.cpp
@@ -154,6 +158,7 @@ target_include_directories(TZshot PRIVATE
src/paint_board/shape
src/model
src/viewmodel
src/scroll
src/shortcut_key
)
@@ -243,6 +248,11 @@ if(WIN32)
set(OCR_THIRDPART_DIR "${CMAKE_CURRENT_SOURCE_DIR}/thirdpart")
set(OCR_INSTALL_DIR "${OCR_THIRDPART_DIR}/ocr-install")
set(OCR_TESSDATA_DIR "${OCR_INSTALL_DIR}/share/tessdata")
# ExternalProject build trees must be generator-specific.
# Otherwise switching between Ninja/VS reuses incompatible CMake caches.
set(OCR_EP_KEY "${CMAKE_GENERATOR}-${CMAKE_GENERATOR_PLATFORM}")
string(REGEX REPLACE "[^A-Za-z0-9_.-]" "_" OCR_EP_KEY "${OCR_EP_KEY}")
set(OCR_EP_ROOT_DIR "${OCR_THIRDPART_DIR}/_ep_${OCR_EP_KEY}")
set(OCR_ZLIB_VERSION "v1.3.1")
set(OCR_LIBPNG_VERSION "v1.6.43")
set(OCR_LIBJPEG_TURBO_VERSION "3.0.3")
@@ -270,7 +280,7 @@ if(WIN32)
endif()
ExternalProject_Add(zlib_ep
PREFIX "${OCR_THIRDPART_DIR}/zlib"
PREFIX "${OCR_EP_ROOT_DIR}/zlib"
GIT_REPOSITORY "https://github.com/madler/zlib.git"
GIT_TAG "${OCR_ZLIB_VERSION}"
UPDATE_DISCONNECTED 1
@@ -282,7 +292,7 @@ if(WIN32)
)
ExternalProject_Add(libpng_ep
PREFIX "${OCR_THIRDPART_DIR}/libpng"
PREFIX "${OCR_EP_ROOT_DIR}/libpng"
GIT_REPOSITORY "https://github.com/pnggroup/libpng.git"
GIT_TAG "${OCR_LIBPNG_VERSION}"
UPDATE_DISCONNECTED 1
@@ -299,7 +309,7 @@ if(WIN32)
)
ExternalProject_Add(libjpeg_ep
PREFIX "${OCR_THIRDPART_DIR}/libjpeg"
PREFIX "${OCR_EP_ROOT_DIR}/libjpeg"
GIT_REPOSITORY "https://github.com/libjpeg-turbo/libjpeg-turbo.git"
GIT_TAG "${OCR_LIBJPEG_TURBO_VERSION}"
UPDATE_DISCONNECTED 1
@@ -314,7 +324,7 @@ if(WIN32)
)
ExternalProject_Add(libtiff_ep
PREFIX "${OCR_THIRDPART_DIR}/libtiff"
PREFIX "${OCR_EP_ROOT_DIR}/libtiff"
GIT_REPOSITORY "https://github.com/libsdl-org/libtiff.git"
GIT_TAG "${OCR_LIBTIFF_VERSION}"
UPDATE_DISCONNECTED 1
@@ -338,7 +348,7 @@ if(WIN32)
)
ExternalProject_Add(leptonica_ep
PREFIX "${OCR_THIRDPART_DIR}/leptonica"
PREFIX "${OCR_EP_ROOT_DIR}/leptonica"
GIT_REPOSITORY "https://github.com/DanBloomberg/leptonica.git"
GIT_TAG "${OCR_LEPTONICA_VERSION}"
UPDATE_DISCONNECTED 1
@@ -361,7 +371,7 @@ if(WIN32)
)
ExternalProject_Add(tesseract_ep
PREFIX "${OCR_THIRDPART_DIR}/tesseract"
PREFIX "${OCR_EP_ROOT_DIR}/tesseract"
GIT_REPOSITORY "https://github.com/tesseract-ocr/tesseract.git"
GIT_TAG "${OCR_TESSERACT_VERSION}"
UPDATE_DISCONNECTED 1
+4
View File
@@ -40,8 +40,12 @@
<file>qml/TZLoadingSpinner.qml</file>
<file>qml/TZGifRecordBar.qml</file>
<file>qml/TZOcrResult.qml</file>
<file>qml/TZLongCaptureBar.qml</file>
<file>qml/TZLongCapturePreview.qml</file>
<file>qml/TZLongCaptureFrame.qml</file>
<file>resource/img/lc_gif_record.svg</file>
<file>resource/img/lc_gif_stop.svg</file>
<file>resource/img/lc_ocr.svg</file>
<file>resource/img/lc_longshot.svg</file>
</qresource>
</RCC>
+109
View File
@@ -0,0 +1,109 @@
import QtQuick
import QtQuick.Window
import QtQuick.Layouts
Window {
id: longBar
width: 500
height: 56
visible: false
color: "transparent"
flags: Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.WindowDoesNotAcceptFocus
signal startRequested()
signal stopRequested()
property bool running: false
property int frameCount: 0
property string message: qsTr("点击开始后,滑动内容自动拼接")
Rectangle {
anchors.fill: parent
radius: 12
color: "#111827"
border.color: "#1F2937"
border.width: 1
RowLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 10
Rectangle {
width: 26
height: 26
radius: 13
color: "#F97316"
Layout.alignment: Qt.AlignVCenter
Text {
anchors.centerIn: parent
text: "长"
color: "#FFFFFF"
font.pixelSize: 14
font.bold: true
}
}
Text {
Layout.fillWidth: true
text: longBar.message
color: "#E5E7EB"
font.pixelSize: 13
elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
}
Text {
text: qsTr("%1 帧").arg(longBar.frameCount)
color: "#D1D5DB"
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
}
Rectangle {
width: 72
height: 30
radius: 6
visible: !longBar.running
color: startArea.containsMouse ? "#1D4ED8" : "#2563EB"
Layout.alignment: Qt.AlignVCenter
Behavior on color { ColorAnimation { duration: 100 } }
Text {
anchors.centerIn: parent
text: qsTr("开始")
color: "#FFFFFF"
font.pixelSize: 13
}
MouseArea {
id: startArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: longBar.startRequested()
}
}
Rectangle {
width: 72
height: 30
radius: 6
color: stopArea.containsMouse ? "#DC2626" : "#EF4444"
Layout.alignment: Qt.AlignVCenter
Behavior on color { ColorAnimation { duration: 100 } }
Text {
anchors.centerIn: parent
text: longBar.running ? qsTr("完成") : qsTr("关闭")
color: "#FFFFFF"
font.pixelSize: 13
}
MouseArea {
id: stopArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: longBar.stopRequested()
}
}
}
}
}
+53
View File
@@ -0,0 +1,53 @@
import QtQuick
import QtQuick.Window
Window {
id: longFrame
visible: false
color: "transparent"
flags: Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.WindowTransparentForInput
property rect captureRect: Qt.rect(0, 0, 0, 0)
x: captureRect.x - 2
y: captureRect.y - 2
width: Math.max(0, captureRect.width) + 4
height: Math.max(0, captureRect.height) + 4
Rectangle {
anchors.fill: parent
color: "transparent"
border.color: "#0EA5E9"
border.width: 2
radius: 2
Rectangle {
x: 0
y: 0
width: 6
height: 6
color: "#0EA5E9"
}
Rectangle {
x: parent.width - width
y: 0
width: 6
height: 6
color: "#0EA5E9"
}
Rectangle {
x: 0
y: parent.height - height
width: 6
height: 6
color: "#0EA5E9"
}
Rectangle {
x: parent.width - width
y: parent.height - height
width: 6
height: 6
color: "#0EA5E9"
}
}
}
+82
View File
@@ -0,0 +1,82 @@
import QtQuick
import QtQuick.Window
Window {
id: longPreview
width: 260
height: 420
visible: false
color: "transparent"
flags: Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.WindowDoesNotAcceptFocus
property string previewUrl: ""
property string statusText: ""
property int frameCount: 0
Rectangle {
anchors.fill: parent
radius: 12
color: "#111827"
border.color: "#1F2937"
border.width: 1
Column {
anchors.fill: parent
anchors.margins: 10
spacing: 8
Row {
width: parent.width
spacing: 8
Text {
text: qsTr("长截图预览")
color: "#F9FAFB"
font.pixelSize: 13
font.bold: true
}
Text {
text: qsTr("%1 帧").arg(longPreview.frameCount)
color: "#9CA3AF"
font.pixelSize: 12
}
}
Rectangle {
width: parent.width
height: parent.height - 58
radius: 8
color: "#0B1220"
border.color: "#334155"
border.width: 1
clip: true
Image {
anchors.fill: parent
anchors.margins: 6
source: longPreview.previewUrl
fillMode: Image.PreserveAspectFit
smooth: true
cache: false
}
Text {
anchors.centerIn: parent
visible: longPreview.previewUrl.length === 0
text: qsTr("暂无预览")
color: "#64748B"
font.pixelSize: 12
}
}
Text {
width: parent.width
text: longPreview.statusText
color: "#CBD5E1"
font.pixelSize: 12
elide: Text.ElideRight
}
}
}
}
+8
View File
@@ -17,6 +17,7 @@ Rectangle {
signal signalSticky
signal signalUndo
signal signalGifRecord
signal signalLongScreenshot
signal signalOcr
property string activeTool: ""
@@ -100,6 +101,13 @@ Rectangle {
MouseArea { id: gifArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("录制GIF"), parent); onExited: tool.hideTip(qsTr("录制GIF")); onPressed: signalGifRecord() }
}
Rectangle {
width: 32; height: 32; radius: 7
color: longShotArea.containsMouse ? "#FFF7ED" : "transparent"
Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_longshot.svg"; width: 18; height: 18 }
MouseArea { id: longShotArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("长截图"), parent); onExited: tool.hideTip(qsTr("长截图")); onPressed: signalLongScreenshot() }
}
Rectangle { width: 1; height: 20; color: "#E2E8F0"; Layout.alignment: Qt.AlignVCenter }
Rectangle {
+6 -1
View File
@@ -592,7 +592,7 @@ Window {
TZTool {
id: tool
width: 620; height: 44
width: 656; height: 44
z: 30
// Toolbar position (local coordinates)
@@ -660,6 +660,11 @@ Window {
})
}
onSignalLongScreenshot: {
var captureRect = screenshotArea
root.beginLongCapture(captureRect)
}
onSignalOcr: {
var captureRect = screenshotArea
Qt.callLater(function() {
+161
View File
@@ -13,6 +13,13 @@ TZWindow {
property var settingWinObj: null
property var aboutWinObj: null
property var longCaptureBarObj: null
property var longCaptureBarComponent: null
property var longCapturePreviewObj: null
property var longCapturePreviewComponent: null
property var longCaptureFrameObj: null
property var longCaptureFrameComponent: null
property rect pendingLongCaptureRect: Qt.rect(0, 0, 0, 0)
property var overlayList: []
property var overlayComponent: null
@@ -130,6 +137,115 @@ TZWindow {
overlayList = []
}
function showLongCaptureBar() {
if (longCaptureBarObj !== null) {
longCaptureBarObj.visible = true
longCaptureBarObj.raise()
return
}
if (longCaptureBarComponent === null)
longCaptureBarComponent = Qt.createComponent("qrc:/qml/TZLongCaptureBar.qml")
if (longCaptureBarComponent.status === Component.Ready) {
longCaptureBarObj = longCaptureBarComponent.createObject(null)
if (longCaptureBarObj) {
longCaptureBarObj.x = Math.round((Screen.width - longCaptureBarObj.width) / 2)
longCaptureBarObj.y = 26
longCaptureBarObj.visible = true
longCaptureBarObj.raise()
longCaptureBarObj.startRequested.connect(function() {
if (pendingLongCaptureRect.width === 0 || pendingLongCaptureRect.height === 0)
return
longCaptureBarObj.running = true
longCaptureBarObj.message = qsTr("滚动检测中,滑动内容会自动拼接")
O_ScrollCaptureVM.start(pendingLongCaptureRect)
})
longCaptureBarObj.stopRequested.connect(function() {
if (longCaptureBarObj.running) {
O_ScrollCaptureVM.stop()
} else {
hideLongCaptureBar()
root.resetArea()
}
})
}
} else if (longCaptureBarComponent.status === Component.Error) {
console.error("LongCaptureBar load failed:", longCaptureBarComponent.errorString())
}
}
function showLongCapturePreview() {
if (longCapturePreviewObj !== null) {
longCapturePreviewObj.visible = true
longCapturePreviewObj.raise()
return
}
if (longCapturePreviewComponent === null)
longCapturePreviewComponent = Qt.createComponent("qrc:/qml/TZLongCapturePreview.qml")
if (longCapturePreviewComponent.status === Component.Ready) {
longCapturePreviewObj = longCapturePreviewComponent.createObject(null)
if (longCapturePreviewObj) {
longCapturePreviewObj.x = Math.max(0, Screen.width - longCapturePreviewObj.width - 26)
longCapturePreviewObj.y = Math.max(20, Math.round((Screen.height - longCapturePreviewObj.height) / 2))
longCapturePreviewObj.visible = true
longCapturePreviewObj.raise()
}
} else if (longCapturePreviewComponent.status === Component.Error) {
console.error("LongCapturePreview load failed:", longCapturePreviewComponent.errorString())
}
}
function showLongCaptureFrame(captureRect) {
if (longCaptureFrameComponent === null)
longCaptureFrameComponent = Qt.createComponent("qrc:/qml/TZLongCaptureFrame.qml")
if (longCaptureFrameObj === null && longCaptureFrameComponent.status === Component.Ready) {
longCaptureFrameObj = longCaptureFrameComponent.createObject(null)
} else if (longCaptureFrameComponent.status === Component.Error) {
console.error("LongCaptureFrame load failed:", longCaptureFrameComponent.errorString())
}
if (longCaptureFrameObj) {
longCaptureFrameObj.captureRect = captureRect
longCaptureFrameObj.visible = true
longCaptureFrameObj.raise()
}
}
function hideLongCaptureBar() {
if (longCaptureBarObj !== null) {
longCaptureBarObj.close()
longCaptureBarObj.destroy(100)
longCaptureBarObj = null
}
if (longCapturePreviewObj !== null) {
longCapturePreviewObj.close()
longCapturePreviewObj.destroy(100)
longCapturePreviewObj = null
}
if (longCaptureFrameObj !== null) {
longCaptureFrameObj.close()
longCaptureFrameObj.destroy(100)
longCaptureFrameObj = null
}
pendingLongCaptureRect = Qt.rect(0, 0, 0, 0)
}
function beginLongCapture(captureRect) {
pendingLongCaptureRect = captureRect
root.visibility = Window.Hidden
showLongCaptureBar()
showLongCapturePreview()
showLongCaptureFrame(captureRect)
if (longCaptureBarObj) {
longCaptureBarObj.running = false
longCaptureBarObj.frameCount = 0
longCaptureBarObj.message = qsTr("点击开始后,滑动内容自动拼接")
}
if (longCapturePreviewObj) {
longCapturePreviewObj.previewUrl = ""
longCapturePreviewObj.frameCount = 0
longCapturePreviewObj.statusText = qsTr("等待开始")
}
}
function resetArea() {
_destroyOverlays()
_prepareSessionState()
@@ -204,6 +320,51 @@ TZWindow {
}
}
Connections {
target: O_ScrollCaptureVM
function onCaptureStarted() {
if (longCaptureBarObj) {
longCaptureBarObj.running = true
longCaptureBarObj.message = qsTr("滚动检测中,滑动内容会自动拼接")
}
trayHelper.showMessage(qsTr("长截图开始"), qsTr("请直接滚动目标内容,系统会在滚动变化时自动拼接"))
}
function onCaptureSucceeded(savedPath) {
if (longCaptureBarObj) {
longCaptureBarObj.running = false
longCaptureBarObj.message = qsTr("长截图完成")
}
root.hideLongCaptureBar()
root.resetArea()
trayHelper.showMessage(qsTr("长截图完成"), qsTr("已复制到剪贴板并保存到:") + savedPath)
}
function onCaptureFailed(errorMessage) {
if (longCaptureBarObj) {
longCaptureBarObj.running = false
longCaptureBarObj.message = qsTr("长截图失败")
}
root.hideLongCaptureBar()
root.resetArea()
trayHelper.showMessage(qsTr("长截图失败"), errorMessage)
}
function onCapturedFramesChanged() {
if (longCaptureBarObj)
longCaptureBarObj.frameCount = O_ScrollCaptureVM.capturedFrames
if (longCapturePreviewObj)
longCapturePreviewObj.frameCount = O_ScrollCaptureVM.capturedFrames
}
function onStatusTextChanged() {
if (longCaptureBarObj)
longCaptureBarObj.message = O_ScrollCaptureVM.statusText
if (longCapturePreviewObj)
longCapturePreviewObj.statusText = O_ScrollCaptureVM.statusText
}
function onPreviewImageUrlChanged() {
if (longCapturePreviewObj)
longCapturePreviewObj.previewUrl = O_ScrollCaptureVM.previewImageUrl
}
}
Connections {
target: O_InstanceBridge
function onActivationRequested() {
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="4" y="3" width="12" height="18" rx="2" ry="2"/>
<polyline points="14 9 18 12 14 15"/>
<line x1="18" y1="12" x2="10" y2="12"/>
</svg>

After

Width:  |  Height:  |  Size: 335 B

+2 -12
View File
@@ -1,14 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<!-- 虚线选区矩形 -->
<rect x="3" y="3" width="26" height="26" rx="2"
fill="none" stroke="#FFFFFF" stroke-width="2"
stroke-dasharray="4,3"/>
<!-- 十字准心横线(中间留空) -->
<line x1="3" y1="16" x2="12" y2="16" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round"/>
<line x1="20" y1="16" x2="29" y2="16" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round"/>
<!-- 十字准心竖线(中间留空) -->
<line x1="16" y1="3" x2="16" y2="12" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round"/>
<line x1="16" y1="20" x2="16" y2="29" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round"/>
<!-- 中心圆点 -->
<circle cx="16" cy="16" r="2.5" fill="#FFFFFF"/>
<rect x="2.5" y="2.5" width="27" height="27" rx="6" fill="#FFFFFF" stroke="#111111" stroke-width="2"/>
<path d="M8 9h16v4h-6v10h-4V13H8z" fill="#111111"/>
</svg>

Before

Width:  |  Height:  |  Size: 825 B

After

Width:  |  Height:  |  Size: 250 B

+3
View File
@@ -21,6 +21,7 @@
#include "viewmodel/ai_view_model.h"
#include "viewmodel/storage_view_model.h"
#include "viewmodel/gif_record_view_model.h"
#include "viewmodel/scroll_capture_view_model.h"
#include "viewmodel/ocr_view_model.h"
int main(int argc, char *argv[])
@@ -51,6 +52,7 @@ int main(int argc, char *argv[])
StorageViewModel storageViewModel(appSettings);
LanguageManager languageManager(appSettings);
GifRecordViewModel gifRecordViewModel(appSettings);
ScrollCaptureViewModel scrollCaptureViewModel(screenshotViewModel, appSettings);
OcrViewModel ocrViewModel;
@@ -78,6 +80,7 @@ int main(int argc, char *argv[])
engine.rootContext()->setContextProperty("O_GlobalShortcut", &globalShortcut);
engine.rootContext()->setContextProperty("O_LanguageManager", &languageManager);
engine.rootContext()->setContextProperty("O_GifRecordVM", &gifRecordViewModel);
engine.rootContext()->setContextProperty("O_ScrollCaptureVM", &scrollCaptureViewModel);
engine.rootContext()->setContextProperty("O_OcrVM", &ocrViewModel);
engine.rootContext()->setContextProperty("O_InstanceBridge", &instanceActivation);
+120
View File
@@ -0,0 +1,120 @@
#include "scroll_stitcher.h"
#include <QPainter>
#include <QtGlobal>
void ScrollStitcher::reset()
{
m_lastFrame = QImage();
m_result = QImage();
}
bool ScrollStitcher::hasBase() const
{
return !m_lastFrame.isNull();
}
void ScrollStitcher::setBaseFrame(const QImage &frame)
{
m_lastFrame = frame;
m_result = frame;
}
int ScrollStitcher::appendFrame(const QImage &frame)
{
if (frame.isNull()) {
return 0;
}
if (m_lastFrame.isNull() || m_result.isNull()) {
setBaseFrame(frame);
return frame.height();
}
QImage curr = frame;
if (curr.size() != m_lastFrame.size()) {
curr = curr.scaled(m_lastFrame.size(), Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
}
const int overlap = findBestOverlap(m_lastFrame, curr);
if (overlap <= 0 || overlap >= curr.height()) {
m_lastFrame = curr;
return 0;
}
const int appendHeight = curr.height() - overlap;
if (appendHeight < 8) {
m_lastFrame = curr;
return 0;
}
QImage merged(m_result.width(), m_result.height() + appendHeight, QImage::Format_ARGB32_Premultiplied);
merged.fill(Qt::transparent);
QPainter painter(&merged);
painter.drawImage(0, 0, m_result);
painter.drawImage(
QRect(0, m_result.height(), curr.width(), appendHeight),
curr,
QRect(0, overlap, curr.width(), appendHeight));
painter.end();
m_result = merged;
m_lastFrame = curr;
return appendHeight;
}
int ScrollStitcher::findBestOverlap(const QImage &prev, const QImage &curr) const
{
const int w = qMin(prev.width(), curr.width());
const int h = qMin(prev.height(), curr.height());
if (w < 80 || h < 80) {
return -1;
}
const int minOverlap = qMax(20, h * 20 / 100);
const int maxOverlap = qMax(minOverlap + 1, h * 85 / 100);
const int step = 3;
int bestOverlap = -1;
qint64 bestScore = std::numeric_limits<qint64>::max();
const int left = w * 10 / 100;
const int right = w * 90 / 100;
for (int overlap = minOverlap; overlap <= maxOverlap; overlap += 2) {
qint64 score = 0;
int samples = 0;
for (int y = 0; y < overlap; y += step) {
const int py = prev.height() - overlap + y;
const int cy = y;
for (int x = left; x < right; x += step) {
const int g1 = grayAt(prev, x, py);
const int g2 = grayAt(curr, x, cy);
score += qAbs(g1 - g2);
++samples;
}
}
if (samples == 0) {
continue;
}
const qint64 norm = score / samples;
if (norm < bestScore) {
bestScore = norm;
bestOverlap = overlap;
}
}
// Empirical threshold for noisy desktop content.
if (bestScore > 22) {
return -1;
}
return bestOverlap;
}
int ScrollStitcher::grayAt(const QImage &img, int x, int y)
{
const QRgb p = img.pixel(x, y);
return (qRed(p) * 30 + qGreen(p) * 59 + qBlue(p) * 11) / 100;
}
+27
View File
@@ -0,0 +1,27 @@
#ifndef SCROLL_STITCHER_H
#define SCROLL_STITCHER_H
#include <QImage>
class ScrollStitcher
{
public:
void reset();
bool hasBase() const;
void setBaseFrame(const QImage &frame);
// Returns appended pixel height. 0 means no new area appended.
int appendFrame(const QImage &frame);
QImage result() const { return m_result; }
private:
int findBestOverlap(const QImage &prev, const QImage &curr) const;
static int grayAt(const QImage &img, int x, int y);
QImage m_lastFrame;
QImage m_result;
};
#endif // SCROLL_STITCHER_H
+16
View File
@@ -133,6 +133,22 @@ QImage ScreenshotViewModel::captureRectToImage(const QRect &rect)
return screen->grabWindow(0, r.x(), r.y(), r.width(), r.height()).toImage();
}
QImage ScreenshotViewModel::captureRectToImageLive(const QRect &rect)
{
const QRect r = rect.normalized();
if (r.isEmpty()) {
return {};
}
m_snapshot.grab();
return captureRectToImage(r);
}
bool ScreenshotViewModel::copyImageToClipboard(const QImage &image)
{
return writeImageToClipboard(image);
}
// ── captureScreen (private) ───────────────────────────────
QImage ScreenshotViewModel::captureScreen(QQuickItem *paintBoard,
const QRect &rect,
+6
View File
@@ -59,6 +59,12 @@ public:
// 截图选区并直接返回 QImage(OCR 使用)
Q_INVOKABLE QImage captureRectToImage(const QRect &rect);
// 实时抓取选区(每次都会刷新桌面快照),用于长截图连续采样。
QImage captureRectToImageLive(const QRect &rect);
// 复制任意图像到剪贴板。
bool copyImageToClipboard(const QImage &image);
private:
// 内部:从快照裁剪选区并叠加 PaintBoard 标注层
// setBackground=true 时将结果写回 PaintBoard 背景(仅马赛克路径)
+221
View File
@@ -0,0 +1,221 @@
#include "scroll_capture_view_model.h"
#include <QDateTime>
#include <QBuffer>
#include <QDir>
#include <QDesktopServices>
#include <QFileInfo>
#include <QUrl>
namespace {
constexpr int kMaxManualFrames = 200;
constexpr int kAutoSampleMs = 90;
constexpr int kProbeWidth = 72;
constexpr int kProbeHeight = 72;
constexpr int kAvgDiffThreshold = 3;
}
ScrollCaptureViewModel::ScrollCaptureViewModel(ScreenshotViewModel &screenCapture,
AppSettings &settings,
QObject *parent)
: QObject(parent)
, m_screenCapture(screenCapture)
, m_settings(settings)
{
m_timer.setInterval(kAutoSampleMs);
connect(&m_timer, &QTimer::timeout, this, [this]() { onTick(); });
}
void ScrollCaptureViewModel::start(const QRect &captureRect)
{
if (m_isCapturing) {
return;
}
const QRect rect = captureRect.normalized();
if (rect.width() < 20 || rect.height() < 20) {
emit captureFailed(tr("长截图区域无效"));
return;
}
m_captureRect = rect;
m_stitcher.reset();
m_capturedFrames = 0;
emit capturedFramesChanged();
const QImage first = m_screenCapture.captureRectToImageLive(m_captureRect);
if (first.isNull()) {
emit captureFailed(tr("首帧采样失败"));
return;
}
m_stitcher.setBaseFrame(first);
m_capturedFrames = 1;
emit capturedFramesChanged();
setPreviewImage(m_stitcher.result());
m_lastProbe = makeProbe(first);
m_isCapturing = true;
emit isCapturingChanged();
emit captureStarted();
setStatus(tr("滚动检测已开启,滑动内容会自动拼接"));
m_timer.start();
}
void ScrollCaptureViewModel::stop()
{
if (!m_isCapturing) {
return;
}
const QImage current = m_stitcher.result();
if (!current.isNull()) {
finishSuccess(current);
} else {
finishWithError(tr("长截图已停止"));
}
}
void ScrollCaptureViewModel::onTick()
{
if (!m_isCapturing) {
return;
}
const QImage frame = m_screenCapture.captureRectToImageLive(m_captureRect);
if (frame.isNull()) {
finishWithError(tr("采样失败,已中断"));
return;
}
if (!hasMeaningfulChange(frame)) {
setStatus(tr("等待滚动变化..."));
return;
}
++m_capturedFrames;
emit capturedFramesChanged();
const int added = m_stitcher.appendFrame(frame);
setPreviewImage(m_stitcher.result());
if (added > 0) {
setStatus(tr("滚动已捕捉,已采样 %1 帧").arg(m_capturedFrames));
} else {
setStatus(tr("检测到变化但拼接增量为 0,请继续滚动"));
}
if (m_capturedFrames >= kMaxManualFrames) {
finishWithError(tr("采样帧数超过上限,请先结束并保存"));
}
}
void ScrollCaptureViewModel::setStatus(const QString &text)
{
if (m_statusText == text) {
return;
}
m_statusText = text;
emit statusTextChanged();
}
QImage ScrollCaptureViewModel::makeProbe(const QImage &image) const
{
if (image.isNull()) {
return QImage();
}
QImage gray = image.convertToFormat(QImage::Format_Grayscale8);
return gray.scaled(kProbeWidth, kProbeHeight, Qt::IgnoreAspectRatio, Qt::FastTransformation);
}
bool ScrollCaptureViewModel::hasMeaningfulChange(const QImage &image)
{
const QImage currentProbe = makeProbe(image);
if (currentProbe.isNull()) {
return false;
}
if (m_lastProbe.isNull() || m_lastProbe.size() != currentProbe.size()) {
m_lastProbe = currentProbe;
return true;
}
qint64 sum = 0;
const int w = currentProbe.width();
const int h = currentProbe.height();
for (int y = 0; y < h; ++y) {
const uchar *a = currentProbe.constScanLine(y);
const uchar *b = m_lastProbe.constScanLine(y);
for (int x = 0; x < w; ++x) {
sum += qAbs(int(a[x]) - int(b[x]));
}
}
const int avg = int(sum / qMax(1, w * h));
const bool changed = avg >= kAvgDiffThreshold;
if (changed) {
m_lastProbe = currentProbe;
}
return changed;
}
void ScrollCaptureViewModel::setPreviewImage(const QImage &image)
{
QString next;
if (!image.isNull()) {
QByteArray bytes;
QBuffer buffer(&bytes);
buffer.open(QIODevice::WriteOnly);
image.save(&buffer, "PNG");
next = QStringLiteral("data:image/png;base64,%1")
.arg(QString::fromLatin1(bytes.toBase64()));
}
if (m_previewImageUrl == next) {
return;
}
m_previewImageUrl = next;
emit previewImageUrlChanged();
}
void ScrollCaptureViewModel::finishWithError(const QString &text)
{
m_timer.stop();
m_isCapturing = false;
emit isCapturingChanged();
setStatus(text);
emit captureFailed(text);
}
void ScrollCaptureViewModel::finishSuccess(const QImage &result)
{
m_timer.stop();
m_isCapturing = false;
emit isCapturingChanged();
if (result.isNull()) {
emit captureFailed(tr("拼接失败,结果为空"));
return;
}
if (result.height() <= m_captureRect.height() + 20) {
emit captureFailed(tr("未检测到有效滚动,请先激活目标窗口后重试"));
return;
}
m_screenCapture.copyImageToClipboard(result);
QString dirPath = m_settings.savePath();
if (dirPath.isEmpty()) {
dirPath = QDir::homePath();
}
QDir().mkpath(dirPath);
const QString fileName = QStringLiteral("long_%1.png")
.arg(QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss"));
const QString savePath = QDir(dirPath).filePath(fileName);
result.save(savePath, "PNG");
const QString saveDirPath = QFileInfo(savePath).absolutePath();
if (!saveDirPath.isEmpty()) {
QDesktopServices::openUrl(QUrl::fromLocalFile(saveDirPath));
}
setStatus(tr("长截图完成"));
emit captureSucceeded(QFileInfo(savePath).absoluteFilePath());
}
+68
View File
@@ -0,0 +1,68 @@
#ifndef SCROLL_CAPTURE_VIEW_MODEL_H
#define SCROLL_CAPTURE_VIEW_MODEL_H
#include <QObject>
#include <QImage>
#include <QRect>
#include <QTimer>
#include <QtGlobal>
#include "model/app_settings.h"
#include "viewmodel/screenshot_view_model.h"
#include "scroll/scroll_stitcher.h"
class ScrollCaptureViewModel : public QObject
{
Q_OBJECT
Q_PROPERTY(bool isCapturing READ isCapturing NOTIFY isCapturingChanged)
Q_PROPERTY(QString statusText READ statusText NOTIFY statusTextChanged)
Q_PROPERTY(int capturedFrames READ capturedFrames NOTIFY capturedFramesChanged)
Q_PROPERTY(QString previewImageUrl READ previewImageUrl NOTIFY previewImageUrlChanged)
public:
explicit ScrollCaptureViewModel(ScreenshotViewModel &screenCapture,
AppSettings &settings,
QObject *parent = nullptr);
bool isCapturing() const { return m_isCapturing; }
QString statusText() const { return m_statusText; }
int capturedFrames() const { return m_capturedFrames; }
QString previewImageUrl() const { return m_previewImageUrl; }
Q_INVOKABLE void start(const QRect &captureRect);
Q_INVOKABLE void stop();
signals:
void captureStarted();
void isCapturingChanged();
void statusTextChanged();
void capturedFramesChanged();
void previewImageUrlChanged();
void captureSucceeded(const QString &savedPath);
void captureFailed(const QString &errorMessage);
private:
void onTick();
void setStatus(const QString &text);
void setPreviewImage(const QImage &image);
QImage makeProbe(const QImage &image) const;
bool hasMeaningfulChange(const QImage &image);
void finishWithError(const QString &text);
void finishSuccess(const QImage &result);
ScreenshotViewModel &m_screenCapture;
AppSettings &m_settings;
ScrollStitcher m_stitcher;
QTimer m_timer;
QRect m_captureRect;
bool m_isCapturing = false;
int m_capturedFrames = 0;
QString m_statusText;
QString m_previewImageUrl;
QImage m_lastProbe;
};
#endif // SCROLL_CAPTURE_VIEW_MODEL_H