diff --git a/CMakeLists.txt b/CMakeLists.txt index f735dd7..17bcefe 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/qml.qrc b/qml.qrc index ebf2bed..da81f24 100644 --- a/qml.qrc +++ b/qml.qrc @@ -40,8 +40,12 @@ qml/TZLoadingSpinner.qml qml/TZGifRecordBar.qml qml/TZOcrResult.qml + qml/TZLongCaptureBar.qml + qml/TZLongCapturePreview.qml + qml/TZLongCaptureFrame.qml resource/img/lc_gif_record.svg resource/img/lc_gif_stop.svg resource/img/lc_ocr.svg + resource/img/lc_longshot.svg diff --git a/qml/TZLongCaptureBar.qml b/qml/TZLongCaptureBar.qml new file mode 100644 index 0000000..d77bc11 --- /dev/null +++ b/qml/TZLongCaptureBar.qml @@ -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() + } + } + } + } +} diff --git a/qml/TZLongCaptureFrame.qml b/qml/TZLongCaptureFrame.qml new file mode 100644 index 0000000..24e2739 --- /dev/null +++ b/qml/TZLongCaptureFrame.qml @@ -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" + } + } +} diff --git a/qml/TZLongCapturePreview.qml b/qml/TZLongCapturePreview.qml new file mode 100644 index 0000000..dcead86 --- /dev/null +++ b/qml/TZLongCapturePreview.qml @@ -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 + } + } + } +} diff --git a/qml/TZTool.qml b/qml/TZTool.qml index fbcd9ad..6482025 100644 --- a/qml/TZTool.qml +++ b/qml/TZTool.qml @@ -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 { diff --git a/qml/TZWindow.qml b/qml/TZWindow.qml index ebcc2d0..acd06d0 100644 --- a/qml/TZWindow.qml +++ b/qml/TZWindow.qml @@ -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() { diff --git a/qml/main.qml b/qml/main.qml index 8402ecb..cabf921 100644 --- a/qml/main.qml +++ b/qml/main.qml @@ -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() { diff --git a/resource/img/lc_longshot.svg b/resource/img/lc_longshot.svg new file mode 100644 index 0000000..246104a --- /dev/null +++ b/resource/img/lc_longshot.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/resource/img/tray_icon.svg b/resource/img/tray_icon.svg index 3f0fb4b..c6e9120 100644 --- a/resource/img/tray_icon.svg +++ b/resource/img/tray_icon.svg @@ -1,14 +1,4 @@ - - - - - - - - - - + + diff --git a/src/main.cpp b/src/main.cpp index 674d9ac..22acacb 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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); diff --git a/src/scroll/scroll_stitcher.cpp b/src/scroll/scroll_stitcher.cpp new file mode 100644 index 0000000..bd55495 --- /dev/null +++ b/src/scroll/scroll_stitcher.cpp @@ -0,0 +1,120 @@ +#include "scroll_stitcher.h" + +#include +#include + +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::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; +} diff --git a/src/scroll/scroll_stitcher.h b/src/scroll/scroll_stitcher.h new file mode 100644 index 0000000..d597ad5 --- /dev/null +++ b/src/scroll/scroll_stitcher.h @@ -0,0 +1,27 @@ +#ifndef SCROLL_STITCHER_H +#define SCROLL_STITCHER_H + +#include + +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 diff --git a/src/viewmodel/screenshot_view_model.cpp b/src/viewmodel/screenshot_view_model.cpp index af401ab..0a36172 100644 --- a/src/viewmodel/screenshot_view_model.cpp +++ b/src/viewmodel/screenshot_view_model.cpp @@ -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, diff --git a/src/viewmodel/screenshot_view_model.h b/src/viewmodel/screenshot_view_model.h index 3cd3453..32889a8 100644 --- a/src/viewmodel/screenshot_view_model.h +++ b/src/viewmodel/screenshot_view_model.h @@ -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 背景(仅马赛克路径) diff --git a/src/viewmodel/scroll_capture_view_model.cpp b/src/viewmodel/scroll_capture_view_model.cpp new file mode 100644 index 0000000..c7b5443 --- /dev/null +++ b/src/viewmodel/scroll_capture_view_model.cpp @@ -0,0 +1,221 @@ +#include "scroll_capture_view_model.h" + +#include +#include +#include +#include +#include +#include + +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()); +} diff --git a/src/viewmodel/scroll_capture_view_model.h b/src/viewmodel/scroll_capture_view_model.h new file mode 100644 index 0000000..51b4e20 --- /dev/null +++ b/src/viewmodel/scroll_capture_view_model.h @@ -0,0 +1,68 @@ +#ifndef SCROLL_CAPTURE_VIEW_MODEL_H +#define SCROLL_CAPTURE_VIEW_MODEL_H + +#include +#include +#include +#include +#include + +#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