feat: add PixPin-style long screenshot with auto scroll-triggered stitching
This commit is contained in:
1 parent
a83d244199
commit
0de4914d0b
17 files changed
+908
-19
No files matched your search
+16
-6
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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 背景(仅马赛克路径)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user