diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ce553ad..6effbe3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,7 +18,7 @@ jobs: CCACHE_DIR: ${{ github.workspace }}/.ccache steps: - name: Install build dependencies - run: pacman -Syu --noconfirm --needed git base-devel cmake ninja ccache dbus ffmpeg glib2 libarchive poppler-qt6 python-gobject qt6-base qt6-declarative qt6-multimedia qt6-multimedia-ffmpeg ttf-dejavu xdg-desktop-portal + run: pacman -Syu --noconfirm --needed git base-devel cmake ninja ccache dbus exiv2 ffmpeg glib2 libarchive poppler-qt6 python-gobject qt6-base qt6-declarative qt6-imageformats qt6-multimedia qt6-multimedia-ffmpeg ttf-dejavu xdg-desktop-portal - uses: actions/checkout@v4 - name: Restore compiler cache uses: actions/cache@v4 diff --git a/.github/workflows/packaging.yml b/.github/workflows/packaging.yml index 5399b69..e73f43c 100644 --- a/.github/workflows/packaging.yml +++ b/.github/workflows/packaging.yml @@ -104,7 +104,7 @@ jobs: - name: Install package build dependencies run: >- pacman -Syu --noconfirm --needed - base-devel cmake ffmpeg git ninja glib2 gvfs libarchive poppler-qt6 qt6-base qt6-declarative qt6-multimedia qt6-multimedia-ffmpeg ttf-dejavu + base-devel cmake exiv2 ffmpeg git ninja glib2 gvfs libarchive poppler-qt6 qt6-base qt6-declarative qt6-imageformats qt6-multimedia qt6-multimedia-ffmpeg ttf-dejavu - name: Checkout exact package source SHA uses: actions/checkout@v4 @@ -209,7 +209,7 @@ jobs: grep -Fxq 'Exec=/usr/bin/ryofiles --filechooser-portal' \ /usr/share/dbus-1/services/org.freedesktop.impl.portal.desktop.ryofiles.service - pacman -Q qt6-multimedia qt6-multimedia-ffmpeg + pacman -Q exiv2 qt6-imageformats qt6-multimedia qt6-multimedia-ffmpeg /usr/bin/ryofiles-portalctl --version ldd /usr/bin/ryofiles | tee /tmp/ryofiles-ldd.txt @@ -237,6 +237,11 @@ jobs: exit 1 } + grep -Eq 'libexiv2\.so' /tmp/ryofiles-preview-helper-ldd.txt || { + echo 'Installed preview helper is not linked against Exiv2.' >&2 + exit 1 + } + for library in libavformat libavcodec libavutil libswscale; do grep -Eq "${library}\\.so" /tmp/ryofiles-preview-helper-ldd.txt || { echo "Installed preview helper is not linked against ${library}." >&2 diff --git a/CMakeLists.txt b/CMakeLists.txt index cf063e3..26982f0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -92,6 +92,7 @@ qt_add_executable(ryofiles src/preview/PreviewFileAccess.hpp src/preview/MediaPreviewWorkflow.hpp src/preview/MediaPreviewWorkflow.cpp + src/preview/ImagePreviewWorkflow.cpp ) qt_add_qml_module(ryofiles @@ -703,4 +704,4 @@ install(TARGETS ryofiles install(FILES assets/ryofiles.desktop DESTINATION ${CMAKE_INSTALL_DATADIR}/applications -) +) \ No newline at end of file diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 6f68c21..47796fb 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -25,3 +25,11 @@ The bounded PDF preview helper dynamically links to the distribution-provided Po ## FFmpeg The bounded audio/video preview helper dynamically links to the distribution-provided FFmpeg libraries (`libavformat`, `libavcodec`, `libavutil`, and `libswscale`). Ryofiles does not vendor FFmpeg source or invoke the `ffmpeg`/`ffprobe` command-line programs for preview decoding. FFmpeg and its enabled components remain subject to their upstream and distribution-provided licenses and copyright notices. + +## Exiv2 + +The bounded image-metadata preview helper dynamically links to the distribution-provided Exiv2 library. Ryofiles passes Exiv2 an in-memory copy read from the already-opened no-follow preview file descriptor; it does not ask Exiv2 to reopen the selected pathname. Exiv2 source is not vendored into Ryofiles and remains subject to its upstream and distribution-provided licenses and copyright notices. + +## Qt image format plugins + +GIF support is provided by Qt's image stack, and additional formats such as WebP are supplied by the distribution-provided Qt image-format plugins. Ryofiles does not vendor those plugins; they remain subject to their upstream and distribution-provided licenses and copyright notices. diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index b73a386..91a67a7 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -7,6 +7,7 @@ arch=('x86_64') url='https://github.com/ashmitvoid/Ryofiles' license=('GPL-3.0-only') depends=( + 'exiv2' 'ffmpeg' 'glib2' 'gvfs' @@ -14,6 +15,7 @@ depends=( 'poppler-qt6' 'qt6-base' 'qt6-declarative' + 'qt6-imageformats' 'qt6-multimedia' 'qt6-multimedia-ffmpeg' 'wayland' diff --git a/qml/components/PreviewPanel.qml b/qml/components/PreviewPanel.qml index f1571ad..feaec03 100644 --- a/qml/components/PreviewPanel.qml +++ b/qml/components/PreviewPanel.qml @@ -19,6 +19,28 @@ Item { details = desktop.propertiesForPath(session.selectedPath) } + function resetImageView() { + imageFlick.zoom = 1.0 + imageFlick.contentX = 0 + imageFlick.contentY = 0 + imageFlick.returnToBounds() + } + + function setImageZoom(value) { + var bounded = Math.max(1.0, Math.min(4.0, value)) + if (Math.abs(imageFlick.zoom - bounded) < 0.001) + return + + var oldWidth = Math.max(1, imageFlick.contentWidth) + var oldHeight = Math.max(1, imageFlick.contentHeight) + var centerX = (imageFlick.contentX + imageFlick.width / 2) / oldWidth + var centerY = (imageFlick.contentY + imageFlick.height / 2) / oldHeight + imageFlick.zoom = bounded + imageFlick.contentX = centerX * imageFlick.contentWidth - imageFlick.width / 2 + imageFlick.contentY = centerY * imageFlick.contentHeight - imageFlick.height / 2 + imageFlick.returnToBounds() + } + function formatBytes(value) { var bytes = Number(value) if (!isFinite(bytes) || bytes < 0) @@ -44,13 +66,28 @@ Item { return "·" } - onSessionChanged: refreshDetails() - onVisibleChanged: refreshDetails() + onSessionChanged: { + refreshDetails() + resetImageView() + } + onVisibleChanged: { + refreshDetails() + if (!visible) + imageAnimation.stop() + } Connections { target: root.session - function onSelectionChanged() { root.refreshDetails() } - function onPathChanged() { root.refreshDetails() } + function onSelectionChanged() { + root.refreshDetails() + root.resetImageView() + imageAnimation.stop() + } + function onPathChanged() { + root.refreshDetails() + root.resetImageView() + imageAnimation.stop() + } } Component.onCompleted: refreshDetails() @@ -73,6 +110,25 @@ Item { path: active ? root.session.selectedPath : "" } + ImagePreviewLoader { + id: imagePreview + active: root.visible + && root.session + && root.session.selectionCount === 1 + && imagePreview.isCandidate(root.session.selectedPath) + path: active ? root.session.selectedPath : "" + } + + ImageAnimationController { + id: imageAnimation + active: root.visible + && root.session + && root.session.selectionCount === 1 + && imagePreview.animationSupported + && !Ryoku.reduceMotion + path: active ? root.session.selectedPath : "" + } + TextPreviewLoader { id: textPreview active: root.visible @@ -134,26 +190,65 @@ Item { border.color: Ryoku.line clip: true - Image { - id: previewImage + Flickable { + id: imageFlick + property real zoom: 1.0 anchors.fill: parent anchors.margins: 8 * root.uiScale visible: root.visible && root.session && root.session.selectionCount === 1 && root.thumbnails.isCandidate(root.session.selectedPath) - source: visible - ? root.thumbnails.urlForPath( - root.session.selectedPath, - Math.round(720 * root.uiScale), - 10) - : "" - sourceSize.width: Math.round(720 * root.uiScale) - sourceSize.height: Math.round(720 * root.uiScale) + && imageAnimation.frameSource === "" + clip: true + interactive: zoom > 1.001 + contentWidth: width * zoom + contentHeight: height * zoom + boundsBehavior: Flickable.StopAtBounds + + Image { + id: previewImage + width: imageFlick.contentWidth + height: imageFlick.contentHeight + source: imageFlick.visible + ? root.thumbnails.urlForPath( + root.session.selectedPath, + Math.round(1024 * root.uiScale), + 10) + : "" + sourceSize.width: Math.round(1024 * root.uiScale) + sourceSize.height: Math.round(1024 * root.uiScale) + fillMode: Image.PreserveAspectFit + cache: false + asynchronous: false + smooth: true + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + onWheel: function(wheel) { + if ((wheel.modifiers & Qt.ControlModifier) === 0) { + wheel.accepted = false + return + } + root.setImageZoom(imageFlick.zoom * (wheel.angleDelta.y > 0 ? 1.2 : 1 / 1.2)) + wheel.accepted = true + } + } + } + + Image { + id: animationImage + anchors.fill: parent + anchors.margins: 8 * root.uiScale + visible: imageAnimation.frameSource !== "" + source: visible ? imageAnimation.frameSource : "" fillMode: Image.PreserveAspectFit cache: false asynchronous: false smooth: true + z: 3 } Image { @@ -274,14 +369,15 @@ Item { visible: !textFlick.visible && !archiveFlick.visible && !mediaPreviewBlock.candidate - && (!previewImage.visible || previewImage.status !== Image.Ready) + && (!imageFlick.visible || previewImage.status !== Image.Ready) + && animationImage.source === "" && (!pdfImage.visible || pdfImage.status !== Image.Ready) text: { if (!root.session || root.session.selectionCount === 0) return "// NO SELECTION" if (root.session.selectionCount > 1) return root.session.selectionCount + " ITEMS SELECTED" - if (previewImage.visible && previewImage.status === Image.Loading) + if (imageFlick.visible && previewImage.status === Image.Loading) return "// LOADING IMAGE…" if (pdfPreview.loading) return "// RENDERING PDF…" @@ -308,6 +404,106 @@ Item { } } + Row { + width: parent.width + height: 26 * root.uiScale + visible: root.session + && root.session.selectionCount === 1 + && imagePreview.isCandidate(root.session.selectedPath) + spacing: 7 * root.uiScale + + Rectangle { + width: 30 * root.uiScale + height: parent.height + radius: 4 * root.uiScale + color: imageZoomOutHover.hovered && imageFlick.zoom > 1.001 ? Ryoku.tint10 : Ryoku.tint5 + border.width: 1 + border.color: Ryoku.line + opacity: imageFlick.zoom > 1.001 ? 1.0 : 0.45 + Text { + anchors.centerIn: parent + text: "−" + color: Ryoku.inkDim + font.family: Ryoku.monoFont + font.pixelSize: 11 * root.uiScale + } + HoverHandler { id: imageZoomOutHover; cursorShape: Qt.PointingHandCursor } + TapHandler { + enabled: imageFlick.zoom > 1.001 + onTapped: root.setImageZoom(imageFlick.zoom / 1.25) + } + } + + Rectangle { + width: 58 * root.uiScale + height: parent.height + radius: 4 * root.uiScale + color: imageFitHover.hovered ? Ryoku.tint10 : Ryoku.tint5 + border.width: 1 + border.color: Ryoku.line + Text { + anchors.centerIn: parent + text: Math.round(imageFlick.zoom * 100) + "%" + color: Ryoku.inkMuted + font.family: Ryoku.monoFont + font.pixelSize: 8 * root.uiScale + } + HoverHandler { id: imageFitHover; cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: root.resetImageView() } + } + + Rectangle { + width: 30 * root.uiScale + height: parent.height + radius: 4 * root.uiScale + color: imageZoomInHover.hovered && imageFlick.zoom < 3.999 ? Ryoku.tint10 : Ryoku.tint5 + border.width: 1 + border.color: Ryoku.line + opacity: imageFlick.zoom < 3.999 ? 1.0 : 0.45 + Text { + anchors.centerIn: parent + text: "+" + color: Ryoku.inkDim + font.family: Ryoku.monoFont + font.pixelSize: 11 * root.uiScale + } + HoverHandler { id: imageZoomInHover; cursorShape: Qt.PointingHandCursor } + TapHandler { + enabled: imageFlick.zoom < 3.999 + onTapped: root.setImageZoom(imageFlick.zoom * 1.25) + } + } + + Rectangle { + width: Math.max(52 * root.uiScale, parent.width - 139 * root.uiScale) + height: parent.height + visible: imagePreview.animated + radius: 4 * root.uiScale + color: imageAnimationHover.hovered && imagePreview.animationSupported && !Ryoku.reduceMotion + ? Ryoku.tint10 : Ryoku.tint5 + border.width: 1 + border.color: Ryoku.line + opacity: imagePreview.animationSupported && !Ryoku.reduceMotion ? 1.0 : 0.45 + Text { + anchors.centerIn: parent + text: imageAnimation.playing ? "■ STOP" : "▶ QUICK LOOK" + color: Ryoku.inkDim + font.family: Ryoku.monoFont + font.pixelSize: 8 * root.uiScale + } + HoverHandler { id: imageAnimationHover; cursorShape: Qt.PointingHandCursor } + TapHandler { + enabled: imagePreview.animationSupported && !Ryoku.reduceMotion + onTapped: { + if (imageAnimation.playing) + imageAnimation.stop() + else + imageAnimation.play() + } + } + } + } + Row { width: parent.width height: 26 * root.uiScale @@ -402,6 +598,105 @@ Item { font.pixelSize: 9 * root.uiScale } + Text { + width: parent.width + visible: imagePreview.supported + text: { + var parts = [] + if (imagePreview.pixelWidth > 0 && imagePreview.pixelHeight > 0) + parts.push(imagePreview.pixelWidth + " × " + imagePreview.pixelHeight) + if (imagePreview.formatName !== "") + parts.push(imagePreview.formatName.toUpperCase()) + if (imagePreview.animated && imagePreview.frameCount > 1) + parts.push(imagePreview.frameCount + " FRAMES") + return "// " + parts.join(" · ") + } + elide: Text.ElideRight + color: Ryoku.inkFaint + font.family: Ryoku.monoFont + font.pixelSize: 8 * root.uiScale + } + + Text { + width: parent.width + visible: imagePreview.supported + && (imagePreview.cameraMake !== "" || imagePreview.cameraModel !== "" || imagePreview.lensModel !== "") + text: { + var parts = [] + var camera = (imagePreview.cameraMake + " " + imagePreview.cameraModel).trim() + if (camera !== "") + parts.push(camera) + if (imagePreview.lensModel !== "") + parts.push(imagePreview.lensModel) + return "// " + parts.join(" · ") + } + maximumLineCount: 2 + elide: Text.ElideRight + wrapMode: Text.WordWrap + color: Ryoku.inkFaint + font.family: Ryoku.monoFont + font.pixelSize: 8 * root.uiScale + } + + Text { + width: parent.width + visible: imagePreview.supported + && (imagePreview.dateTaken !== "" || imagePreview.exposureTime !== "" + || imagePreview.aperture !== "" || imagePreview.iso !== "" || imagePreview.focalLength !== "") + text: { + var parts = [] + if (imagePreview.dateTaken !== "") + parts.push(imagePreview.dateTaken) + if (imagePreview.exposureTime !== "") + parts.push(imagePreview.exposureTime) + if (imagePreview.aperture !== "") + parts.push("f/" + imagePreview.aperture) + if (imagePreview.iso !== "") + parts.push("ISO " + imagePreview.iso) + if (imagePreview.focalLength !== "") + parts.push(imagePreview.focalLength) + return "// " + parts.join(" · ") + } + maximumLineCount: 2 + elide: Text.ElideRight + wrapMode: Text.WordWrap + color: Ryoku.inkFaint + font.family: Ryoku.monoFont + font.pixelSize: 8 * root.uiScale + } + + Text { + width: parent.width + visible: imagePreview.metadataLimited + text: "// DEEP IMAGE METADATA SKIPPED — 64 MiB SAFETY LIMIT" + wrapMode: Text.WordWrap + color: Ryoku.inkFaint + font.family: Ryoku.monoFont + font.pixelSize: 8 * root.uiScale + } + + Text { + width: parent.width + visible: imagePreview.animated && !imagePreview.animationSupported + text: "// ANIMATION EXCEEDS BOUNDED QUICK LOOK LIMITS" + wrapMode: Text.WordWrap + color: Ryoku.inkFaint + font.family: Ryoku.monoFont + font.pixelSize: 8 * root.uiScale + } + + Text { + width: parent.width + visible: imageAnimation.error !== "" + text: "// " + imageAnimation.error + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + color: Ryoku.inkFaint + font.family: Ryoku.monoFont + font.pixelSize: 8 * root.uiScale + } + Text { width: parent.width visible: pdfPreview.supported @@ -462,6 +757,19 @@ Item { font.pixelSize: 8 * root.uiScale } + Text { + width: parent.width + visible: imagePreview.isCandidate(root.session ? root.session.selectedPath : "") + && imagePreview.error !== "" + text: "// " + imagePreview.error + wrapMode: Text.WordWrap + maximumLineCount: 3 + elide: Text.ElideRight + color: Ryoku.inkFaint + font.family: Ryoku.monoFont + font.pixelSize: 8 * root.uiScale + } + Text { width: parent.width visible: textPreview.supported && textPreview.truncated diff --git a/src/preview/ImagePreviewWorkflow.cpp b/src/preview/ImagePreviewWorkflow.cpp new file mode 100644 index 0000000..045c3c4 --- /dev/null +++ b/src/preview/ImagePreviewWorkflow.cpp @@ -0,0 +1,388 @@ +// SPDX-License-Identifier: GPL-3.0-only + +#include "TextPreviewLoader.hpp" +#include "PreviewProtocol.hpp" +#include "PreviewScheduler.hpp" + +#include +#include +#include +#include +#include + +#include + +namespace { + +bool isImageCandidatePath(const QString& path) { + const QFileInfo info(path); + if (!info.isAbsolute()) + return false; + + static const QSet suffixes { + QStringLiteral("jpg"), QStringLiteral("jpeg"), QStringLiteral("png"), + QStringLiteral("webp"), QStringLiteral("gif"), QStringLiteral("bmp"), + QStringLiteral("tif"), QStringLiteral("tiff"), QStringLiteral("avif"), + QStringLiteral("heic"), QStringLiteral("heif"), + }; + return suffixes.contains(info.suffix().toLower()); +} + +void registerImagePreviewQmlTypes() { + qmlRegisterType( + "Ryofiles.Core", 1, 0, "ImagePreviewLoader"); + qmlRegisterType( + "Ryofiles.Core", 1, 0, "ImageAnimationController"); +} + +} // namespace + +Q_COREAPP_STARTUP_FUNCTION(registerImagePreviewQmlTypes) + +ImagePreviewLoader::ImagePreviewLoader(QObject* parent) + : QObject(parent) { + m_debounce.setSingleShot(true); + m_debounce.setInterval(75); + connect(&m_debounce, &QTimer::timeout, this, &ImagePreviewLoader::startLoad); +} + +ImagePreviewLoader::~ImagePreviewLoader() { + PreviewScheduler::instance().cancelOwner(this); +} + +void ImagePreviewLoader::setPath(const QString& path) { + if (m_path == path) + return; + m_path = path; + emit pathChanged(); + scheduleLoad(); +} + +void ImagePreviewLoader::setActive(bool active) { + if (m_active == active) + return; + m_active = active; + emit activeChanged(); + scheduleLoad(); +} + +bool ImagePreviewLoader::isCandidate(const QString& path) const { + return isImageCandidatePath(path); +} + +void ImagePreviewLoader::setLoading(bool loading) { + if (m_loading == loading) + return; + m_loading = loading; + emit loadingChanged(); +} + +void ImagePreviewLoader::clearResult() { + const bool alreadyClear = !m_supported && m_pixelWidth == 0 && m_pixelHeight == 0 + && m_formatName.isEmpty() && !m_metadataAvailable && !m_metadataLimited + && !m_animated && !m_animationSupported && m_frameCount == 0 + && m_cameraMake.isEmpty() && m_cameraModel.isEmpty() && m_lensModel.isEmpty() + && m_dateTaken.isEmpty() && m_exposureTime.isEmpty() && m_aperture.isEmpty() + && m_iso.isEmpty() && m_focalLength.isEmpty() && m_orientation.isEmpty() + && m_exposureBias.isEmpty() && m_whiteBalance.isEmpty() + && m_colorSpace.isEmpty() && m_software.isEmpty() && m_artist.isEmpty() + && m_copyright.isEmpty() && m_error.isEmpty(); + if (alreadyClear) + return; + + m_supported = false; + m_pixelWidth = 0; + m_pixelHeight = 0; + m_formatName.clear(); + m_metadataAvailable = false; + m_metadataLimited = false; + m_animated = false; + m_animationSupported = false; + m_frameCount = 0; + m_cameraMake.clear(); + m_cameraModel.clear(); + m_lensModel.clear(); + m_dateTaken.clear(); + m_exposureTime.clear(); + m_aperture.clear(); + m_iso.clear(); + m_focalLength.clear(); + m_orientation.clear(); + m_exposureBias.clear(); + m_whiteBalance.clear(); + m_colorSpace.clear(); + m_software.clear(); + m_artist.clear(); + m_copyright.clear(); + m_error.clear(); + emit resultChanged(); +} + +void ImagePreviewLoader::scheduleLoad() { + ++m_generation; + m_debounce.stop(); + PreviewScheduler::instance().cancelOwner(this); + setLoading(false); + clearResult(); + + if (!m_active || m_path.isEmpty() || !isImageCandidatePath(m_path)) + return; + m_debounce.start(); +} + +void ImagePreviewLoader::startLoad() { + if (!m_active || m_path.isEmpty() || !isImageCandidatePath(m_path)) + return; + + const quint64 generation = m_generation; + const QString loadPath = m_path; + setLoading(true); + + QJsonObject request; + request.insert(QStringLiteral("op"), QStringLiteral("image-probe")); + request.insert(QStringLiteral("path"), loadPath); + + const bool admitted = PreviewScheduler::instance().submit( + PreviewScheduler::Lane::InteractivePreview, + this, + request, + [this, generation, loadPath](PreviewResult result) { + if (generation != m_generation || loadPath != m_path || !m_active) + return; + + setLoading(false); + if (!result.ok) { + m_supported = false; + m_error = result.error.isEmpty() + ? QStringLiteral("Image metadata unavailable") + : result.error; + emit resultChanged(); + return; + } + + const QJsonObject payload = result.payload; + m_pixelWidth = payload.value(QStringLiteral("pixelWidth")).toInt(0); + m_pixelHeight = payload.value(QStringLiteral("pixelHeight")).toInt(0); + m_formatName = payload.value(QStringLiteral("format")).toString(); + m_metadataAvailable = payload.value(QStringLiteral("metadataAvailable")).toBool(false); + m_metadataLimited = payload.value(QStringLiteral("metadataLimited")).toBool(false); + m_animated = payload.value(QStringLiteral("animated")).toBool(false); + m_animationSupported = payload.value(QStringLiteral("animationSupported")).toBool(false); + m_frameCount = payload.value(QStringLiteral("frameCount")).toInt(0); + m_cameraMake = payload.value(QStringLiteral("cameraMake")).toString(); + m_cameraModel = payload.value(QStringLiteral("cameraModel")).toString(); + m_lensModel = payload.value(QStringLiteral("lensModel")).toString(); + m_dateTaken = payload.value(QStringLiteral("dateTaken")).toString(); + m_exposureTime = payload.value(QStringLiteral("exposureTime")).toString(); + m_aperture = payload.value(QStringLiteral("aperture")).toString(); + m_iso = payload.value(QStringLiteral("iso")).toString(); + m_focalLength = payload.value(QStringLiteral("focalLength")).toString(); + m_orientation = payload.value(QStringLiteral("orientation")).toString(); + m_exposureBias = payload.value(QStringLiteral("exposureBias")).toString(); + m_whiteBalance = payload.value(QStringLiteral("whiteBalance")).toString(); + m_colorSpace = payload.value(QStringLiteral("colorSpace")).toString(); + m_software = payload.value(QStringLiteral("software")).toString(); + m_artist = payload.value(QStringLiteral("artist")).toString(); + m_copyright = payload.value(QStringLiteral("copyright")).toString(); + m_supported = m_pixelWidth > 0 && m_pixelHeight > 0; + m_error.clear(); + emit resultChanged(); + }); + + if (!admitted) { + setLoading(false); + m_supported = false; + m_error = QStringLiteral("Preview queue is busy"); + emit resultChanged(); + } +} + +ImageAnimationController::ImageAnimationController(QObject* parent) + : QObject(parent) { + m_frameTimer.setSingleShot(true); + connect(&m_frameTimer, &QTimer::timeout, this, [this] { + if (!m_playing) + return; + if (m_frameCount > 0 && m_frame + 1 >= m_frameCount) { + stopInternal(); + return; + } + requestNext(); + }); +} + +ImageAnimationController::~ImageAnimationController() { + stopInternal(); +} + +void ImageAnimationController::setPath(const QString& path) { + if (m_path == path) + return; + stopInternal(); + m_path = path; + emit pathChanged(); +} + +void ImageAnimationController::setActive(bool active) { + if (m_active == active) + return; + m_active = active; + if (!m_active) + stopInternal(); + emit activeChanged(); +} + +void ImageAnimationController::play() { + if (m_playing) + return; + if (!m_active || m_path.isEmpty() || !isImageCandidatePath(m_path)) { + m_error = QStringLiteral("Animation Quick Look is unavailable for this selection"); + emit playbackChanged(); + return; + } + + ++m_generation; + PreviewScheduler::instance().cancelOwner(this); + m_frameTimer.stop(); + m_session = QUuid::createUuid().toString(QUuid::WithoutBraces); + m_frameSource.clear(); + m_frame = -1; + m_frameCount = 0; + m_error.clear(); + m_playing = true; + m_elapsed.start(); + emit playbackChanged(); + requestStart(); +} + +void ImageAnimationController::stop() { + stopInternal(); +} + +void ImageAnimationController::requestStart() { + const quint64 generation = m_generation; + const QString loadPath = m_path; + const QString session = m_session; + + QJsonObject request; + request.insert(QStringLiteral("op"), QStringLiteral("image-animation-start")); + request.insert(QStringLiteral("path"), loadPath); + request.insert(QStringLiteral("session"), session); + request.insert(QStringLiteral("maxWidth"), 1000); + request.insert(QStringLiteral("maxHeight"), 700); + + const bool admitted = PreviewScheduler::instance().submit( + PreviewScheduler::Lane::ExplicitLongWork, + this, + request, + [this, generation, loadPath, session](PreviewResult result) { + if (!m_playing || generation != m_generation || loadPath != m_path + || session != m_session || !m_active) { + return; + } + if (!result.ok) { + fail(result.error.isEmpty() + ? QStringLiteral("Animation Quick Look unavailable") + : result.error); + return; + } + applyFrame(result.payload); + }); + + if (!admitted) + fail(QStringLiteral("Preview queue is busy")); +} + +void ImageAnimationController::requestNext() { + if (!m_playing || !m_active) + return; + if (!m_elapsed.isValid() || m_elapsed.elapsed() >= PreviewProtocol::kMaxAnimationPlayMs) { + stopInternal(); + return; + } + + const quint64 generation = m_generation; + const QString session = m_session; + + QJsonObject request; + request.insert(QStringLiteral("op"), QStringLiteral("image-animation-next")); + request.insert(QStringLiteral("session"), session); + + const bool admitted = PreviewScheduler::instance().submit( + PreviewScheduler::Lane::ExplicitLongWork, + this, + request, + [this, generation, session](PreviewResult result) { + if (!m_playing || generation != m_generation || session != m_session || !m_active) + return; + if (!result.ok) { + fail(result.error.isEmpty() + ? QStringLiteral("Animation Quick Look unavailable") + : result.error); + return; + } + applyFrame(result.payload); + }); + + if (!admitted) + fail(QStringLiteral("Preview queue is busy")); +} + +void ImageAnimationController::applyFrame(const QJsonObject& payload) { + const QString session = payload.value(QStringLiteral("session")).toString(); + const QString base64 = payload.value(QStringLiteral("imageBase64")).toString(); + const int frame = payload.value(QStringLiteral("frame")).toInt(-1); + const int frameCount = payload.value(QStringLiteral("frameCount")).toInt(0); + int delayMs = payload.value(QStringLiteral("delayMs")).toInt(100); + + if (session != m_session || base64.isEmpty() || frame < 0 || frameCount <= 1 + || frame >= frameCount || frameCount > PreviewProtocol::kMaxAnimationFrames) { + fail(QStringLiteral("Animation helper returned an invalid frame")); + return; + } + + delayMs = std::clamp( + delayMs, + PreviewProtocol::kMinAnimationDelayMs, + PreviewProtocol::kMaxAnimationDelayMs); + m_frameSource = QStringLiteral("data:image/png;base64,") + base64; + m_frame = frame; + m_frameCount = frameCount; + m_error.clear(); + emit playbackChanged(); + + if (m_elapsed.elapsed() >= PreviewProtocol::kMaxAnimationPlayMs) { + stopInternal(); + return; + } + m_frameTimer.start(delayMs); +} + +void ImageAnimationController::fail(const QString& error) { + ++m_generation; + m_frameTimer.stop(); + PreviewScheduler::instance().cancelOwner(this); + m_session.clear(); + m_frameSource.clear(); + m_frame = -1; + m_frameCount = 0; + m_playing = false; + m_error = error; + emit playbackChanged(); +} + +void ImageAnimationController::stopInternal() { + const bool hadState = m_playing || !m_session.isEmpty() || !m_frameSource.isEmpty() + || m_frame >= 0 || m_frameCount != 0 || !m_error.isEmpty(); + ++m_generation; + m_frameTimer.stop(); + PreviewScheduler::instance().cancelOwner(this); + m_session.clear(); + m_frameSource.clear(); + m_frame = -1; + m_frameCount = 0; + m_playing = false; + m_error.clear(); + if (hadState) + emit playbackChanged(); +} diff --git a/src/preview/ImageProbe.cpp b/src/preview/ImageProbe.cpp new file mode 100644 index 0000000..24af4b3 --- /dev/null +++ b/src/preview/ImageProbe.cpp @@ -0,0 +1,471 @@ +// SPDX-License-Identifier: GPL-3.0-only + +#include "ImageProbe.hpp" +#include "PreviewProtocol.hpp" + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace { + +QString boundedMetadata(QString value) { + value = value.simplified(); + if (value.size() > PreviewProtocol::kMaxImageMetadataChars) + value.truncate(PreviewProtocol::kMaxImageMetadataChars); + return value; +} + +QString boundedMetadata(const std::string& value) { + return boundedMetadata(QString::fromStdString(value)); +} + +QString exifValue(const Exiv2::ExifData& data, const char* key) { + try { + const auto it = data.findKey(Exiv2::ExifKey(key)); + if (it == data.end()) + return {}; + return boundedMetadata(it->toString()); + } catch (const Exiv2::Error&) { + return {}; + } catch (const std::exception&) { + return {}; + } +} + +QString firstExifValue( + const Exiv2::ExifData& data, + std::initializer_list keys) { + for (const char* key : keys) { + const QString value = exifValue(data, key); + if (!value.isEmpty()) + return value; + } + return {}; +} + +void insertIfNotEmpty(QJsonObject& payload, const QString& key, const QString& value) { + if (!value.isEmpty()) + payload.insert(key, value); +} + +int safeDimension(uint32_t value) { + if (value == 0 || value > static_cast(std::numeric_limits::max())) + return 0; + return static_cast(value); +} + +bool dimensionsWithinAnimationBounds(const QSize& size) { + if (!size.isValid() || size.width() <= 0 || size.height() <= 0) + return false; + if (size.width() > PreviewProtocol::kMaxAnimationSourceDimension + || size.height() > PreviewProtocol::kMaxAnimationSourceDimension) { + return false; + } + const qint64 pixels = + static_cast(size.width()) * static_cast(size.height()); + return pixels > 0 && pixels <= PreviewProtocol::kMaxAnimationSourcePixels; +} + +QByteArray encodePngBounded(QImage* image, QString* error) { + if (!image || image->isNull()) { + if (error) + *error = QStringLiteral("Animation frame did not decode"); + return {}; + } + + for (int attempt = 0; attempt < 10; ++attempt) { + QByteArray encoded; + QBuffer buffer(&encoded); + if (!buffer.open(QIODevice::WriteOnly) || !image->save(&buffer, "PNG")) { + if (error) + *error = QStringLiteral("Could not encode animation frame"); + return {}; + } + if (encoded.size() <= PreviewProtocol::kMaxEncodedImageBytes) + return encoded; + + if (image->width() <= 320 || image->height() <= 320) + break; + + const int nextWidth = std::max(320, qRound(image->width() * 0.80)); + const int nextHeight = std::max(320, qRound(image->height() * 0.80)); + *image = image->scaled( + nextWidth, + nextHeight, + Qt::KeepAspectRatio, + Qt::SmoothTransformation); + } + + if (error) + *error = QStringLiteral("Animation frame exceeded the output limit"); + return {}; +} + +} // namespace + +namespace ImageProbe { + +QJsonObject probe( + QFile& file, + qint64 fileSize, + const QJsonObject& request, + QString* error) { + if (fileSize < 0) { + if (error) + *error = QStringLiteral("Image size is invalid"); + return {}; + } + + QJsonObject payload; + bool identified = false; + int pixelWidth = 0; + int pixelHeight = 0; + QString formatName; + QString mimeType; + + if (file.seek(0)) { + QImageReader reader(&file); + reader.setDecideFormatFromContent(true); + reader.setAutoTransform(true); + if (reader.canRead()) { + identified = true; + formatName = QString::fromLatin1(reader.format()).toLower(); + const QSize size = reader.size(); + if (size.isValid()) { + pixelWidth = size.width(); + pixelHeight = size.height(); + } + } + } + + bool metadataAvailable = false; + const bool metadataLimited = + fileSize > PreviewProtocol::kMaxImageMetadataInputBytes; + + if (!metadataLimited && file.seek(0)) { + const QByteArray bytes = file.readAll(); + if (static_cast(bytes.size()) == fileSize && !bytes.isEmpty()) { + try { + auto image = Exiv2::ImageFactory::open( + reinterpret_cast(bytes.constData()), + static_cast(bytes.size())); + if (image) { + image->readMetadata(); + identified = true; + + const int exivWidth = safeDimension(image->pixelWidth()); + const int exivHeight = safeDimension(image->pixelHeight()); + if (pixelWidth <= 0 && exivWidth > 0) + pixelWidth = exivWidth; + if (pixelHeight <= 0 && exivHeight > 0) + pixelHeight = exivHeight; + + mimeType = boundedMetadata(image->mimeType()); + if (formatName.isEmpty() && mimeType.startsWith(QStringLiteral("image/"))) + formatName = mimeType.mid(6).toLower(); + + const Exiv2::ExifData& exif = image->exifData(); + insertIfNotEmpty( + payload, QStringLiteral("cameraMake"), + exifValue(exif, "Exif.Image.Make")); + insertIfNotEmpty( + payload, QStringLiteral("cameraModel"), + exifValue(exif, "Exif.Image.Model")); + insertIfNotEmpty( + payload, QStringLiteral("lensModel"), + exifValue(exif, "Exif.Photo.LensModel")); + insertIfNotEmpty( + payload, QStringLiteral("dateTaken"), + firstExifValue(exif, { + "Exif.Photo.DateTimeOriginal", + "Exif.Photo.DateTimeDigitized", + "Exif.Image.DateTime", + })); + insertIfNotEmpty( + payload, QStringLiteral("exposureTime"), + exifValue(exif, "Exif.Photo.ExposureTime")); + insertIfNotEmpty( + payload, QStringLiteral("aperture"), + exifValue(exif, "Exif.Photo.FNumber")); + insertIfNotEmpty( + payload, QStringLiteral("iso"), + firstExifValue(exif, { + "Exif.Photo.PhotographicSensitivity", + "Exif.Photo.ISOSpeedRatings", + })); + insertIfNotEmpty( + payload, QStringLiteral("focalLength"), + exifValue(exif, "Exif.Photo.FocalLength")); + insertIfNotEmpty( + payload, QStringLiteral("orientation"), + exifValue(exif, "Exif.Image.Orientation")); + insertIfNotEmpty( + payload, QStringLiteral("exposureBias"), + exifValue(exif, "Exif.Photo.ExposureBiasValue")); + insertIfNotEmpty( + payload, QStringLiteral("whiteBalance"), + exifValue(exif, "Exif.Photo.WhiteBalance")); + insertIfNotEmpty( + payload, QStringLiteral("colorSpace"), + exifValue(exif, "Exif.Photo.ColorSpace")); + insertIfNotEmpty( + payload, QStringLiteral("software"), + exifValue(exif, "Exif.Image.Software")); + insertIfNotEmpty( + payload, QStringLiteral("artist"), + exifValue(exif, "Exif.Image.Artist")); + insertIfNotEmpty( + payload, QStringLiteral("copyright"), + exifValue(exif, "Exif.Image.Copyright")); + metadataAvailable = !payload.isEmpty(); + } + } catch (const Exiv2::Error&) { + // A valid image can contain malformed or unsupported metadata. + // Keep the bounded Qt header result rather than failing the preview. + } catch (const std::exception&) { + // Same rule: metadata failure must not take down an otherwise valid image. + } + } + } + + if (!identified) { + if (error) + *error = QStringLiteral("Could not identify image"); + return {}; + } + + const QString suffix = QFileInfo( + request.value(QStringLiteral("path")).toString()).suffix().toLower(); + + bool animated = false; + bool animationSupported = false; + int frameCount = 0; + int loopCount = 0; + + if (fileSize <= PreviewProtocol::kMaxAnimationInputBytes && file.seek(0)) { + QImageReader animationReader(&file); + animationReader.setDecideFormatFromContent(true); + const QString animationFormat = + QString::fromLatin1(animationReader.format()).toLower(); + const bool animationFormatCandidate = + animationFormat == QStringLiteral("gif") + || animationFormat == QStringLiteral("webp") + || suffix == QStringLiteral("gif") + || suffix == QStringLiteral("webp"); + + if (animationFormatCandidate && animationReader.supportsAnimation()) { + frameCount = animationReader.imageCount(); + animated = frameCount > 1; + loopCount = animationReader.loopCount(); + + QSize sourceSize = animationReader.size(); + if (!sourceSize.isValid() && pixelWidth > 0 && pixelHeight > 0) + sourceSize = QSize(pixelWidth, pixelHeight); + + animationSupported = + animated + && frameCount <= PreviewProtocol::kMaxAnimationFrames + && dimensionsWithinAnimationBounds(sourceSize); + } + } + + payload.insert(QStringLiteral("pixelWidth"), pixelWidth); + payload.insert(QStringLiteral("pixelHeight"), pixelHeight); + payload.insert(QStringLiteral("format"), formatName); + payload.insert(QStringLiteral("mime"), mimeType); + payload.insert(QStringLiteral("metadataAvailable"), metadataAvailable); + payload.insert(QStringLiteral("metadataLimited"), metadataLimited); + payload.insert(QStringLiteral("animated"), animated); + payload.insert(QStringLiteral("animationSupported"), animationSupported); + payload.insert(QStringLiteral("frameCount"), frameCount); + payload.insert(QStringLiteral("loopCount"), loopCount); + payload.insert(QStringLiteral("fileSize"), QString::number(fileSize)); + return payload; +} + +AnimationSession::AnimationSession( + QByteArray bytes, + QString token, + int frameCount, + int maxWidth, + int maxHeight) + : m_bytes(std::move(bytes)) + , m_buffer(std::make_unique(&m_bytes)) + , m_token(std::move(token)) + , m_frameCount(frameCount) + , m_maxWidth(maxWidth) + , m_maxHeight(maxHeight) { + m_buffer->open(QIODevice::ReadOnly); + m_reader = std::make_unique(m_buffer.get()); + m_reader->setDecideFormatFromContent(true); + m_reader->setAutoTransform(true); +} + +AnimationSession::~AnimationSession() = default; + +std::unique_ptr AnimationSession::create( + PreviewFileAccess::OpenedFile opened, + const QJsonObject& request, + QString* error) { + if (!opened || opened.size < 0 + || opened.size > PreviewProtocol::kMaxAnimationInputBytes) { + if (error) + *error = QStringLiteral("Animation exceeds the preview input limit"); + return {}; + } + + const QString token = request.value(QStringLiteral("session")).toString(); + if (token.isEmpty() || token.size() > 128 || token.contains(QChar::Null)) { + if (error) + *error = QStringLiteral("Animation session token is invalid"); + return {}; + } + + if (!opened.file->seek(0)) { + if (error) + *error = QStringLiteral("Could not rewind animation"); + return {}; + } + QByteArray bytes = opened.file->readAll(); + if (static_cast(bytes.size()) != opened.size || bytes.isEmpty()) { + if (error) + *error = QStringLiteral("Could not read bounded animation input"); + return {}; + } + + int frameCount = 0; + QSize sourceSize; + { + QBuffer buffer(&bytes); + if (!buffer.open(QIODevice::ReadOnly)) { + if (error) + *error = QStringLiteral("Could not open bounded animation buffer"); + return {}; + } + QImageReader reader(&buffer); + reader.setDecideFormatFromContent(true); + reader.setAutoTransform(true); + if (!reader.canRead()) { + if (error) + *error = QStringLiteral("Could not read animation"); + return {}; + } + + const QString format = QString::fromLatin1(reader.format()).toLower(); + if (format != QStringLiteral("gif") && format != QStringLiteral("webp")) { + if (error) + *error = QStringLiteral("Only GIF and WebP Quick Look are supported"); + return {}; + } + if (!reader.supportsAnimation()) { + if (error) + *error = QStringLiteral("Image is not animated"); + return {}; + } + + frameCount = reader.imageCount(); + if (frameCount <= 1 || frameCount > PreviewProtocol::kMaxAnimationFrames) { + if (error) + *error = QStringLiteral("Animation frame count exceeds the preview limit"); + return {}; + } + + sourceSize = reader.size(); + if (!dimensionsWithinAnimationBounds(sourceSize)) { + if (error) + *error = QStringLiteral("Animation dimensions exceed the preview limit"); + return {}; + } + } + + const int maxWidth = std::clamp( + request.value(QStringLiteral("maxWidth")) + .toInt(PreviewProtocol::kDefaultAnimationFrameWidth), + 128, + PreviewProtocol::kMaxAnimationFrameDimension); + const int maxHeight = std::clamp( + request.value(QStringLiteral("maxHeight")) + .toInt(PreviewProtocol::kDefaultAnimationFrameHeight), + 128, + PreviewProtocol::kMaxAnimationFrameDimension); + + return std::unique_ptr(new AnimationSession( + std::move(bytes), + token, + frameCount, + maxWidth, + maxHeight)); +} + +QJsonObject AnimationSession::readNext(QString* error) { + if (!m_reader || m_nextFrame < 0 || m_nextFrame >= m_frameCount) { + if (error) + *error = QStringLiteral("Animation has no more frames"); + return {}; + } + + QImage frame = m_reader->read(); + if (frame.isNull()) { + if (error) + *error = m_reader->errorString().isEmpty() + ? QStringLiteral("Could not decode animation frame") + : m_reader->errorString(); + return {}; + } + + if (frame.width() > PreviewProtocol::kMaxAnimationSourceDimension + || frame.height() > PreviewProtocol::kMaxAnimationSourceDimension + || frame.sizeInBytes() > PreviewProtocol::kMaxPublishedImageBytes) { + if (error) + *error = QStringLiteral("Animation frame exceeded the decoded-image limit"); + return {}; + } + + if (frame.width() > m_maxWidth || frame.height() > m_maxHeight) { + frame = frame.scaled( + m_maxWidth, + m_maxHeight, + Qt::KeepAspectRatio, + Qt::SmoothTransformation); + } + + QByteArray png = encodePngBounded(&frame, error); + if (png.isEmpty()) + return {}; + + int delayMs = m_reader->nextImageDelay(); + if (delayMs <= 0) + delayMs = 100; + delayMs = std::clamp( + delayMs, + PreviewProtocol::kMinAnimationDelayMs, + PreviewProtocol::kMaxAnimationDelayMs); + + QJsonObject payload; + payload.insert(QStringLiteral("session"), m_token); + payload.insert(QStringLiteral("frame"), m_nextFrame); + payload.insert(QStringLiteral("frameCount"), m_frameCount); + payload.insert(QStringLiteral("delayMs"), delayMs); + payload.insert(QStringLiteral("pixelWidth"), frame.width()); + payload.insert(QStringLiteral("pixelHeight"), frame.height()); + payload.insert(QStringLiteral("imageFormat"), QStringLiteral("png")); + payload.insert( + QStringLiteral("imageBase64"), + QString::fromLatin1(png.toBase64())); + + ++m_nextFrame; + return payload; +} + +} // namespace ImageProbe diff --git a/src/preview/ImageProbe.hpp b/src/preview/ImageProbe.hpp new file mode 100644 index 0000000..de47ea8 --- /dev/null +++ b/src/preview/ImageProbe.hpp @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0-only +#pragma once + +#include "PreviewFileAccess.hpp" + +#include +#include + +#include + +class QBuffer; +class QImageReader; + +namespace ImageProbe { + +QJsonObject probe( + QFile& file, + qint64 fileSize, + const QJsonObject& request, + QString* error); + +class AnimationSession final { +public: + static std::unique_ptr create( + PreviewFileAccess::OpenedFile opened, + const QJsonObject& request, + QString* error); + + ~AnimationSession(); + + QString token() const { return m_token; } + QJsonObject readNext(QString* error); + +private: + AnimationSession( + QByteArray bytes, + QString token, + int frameCount, + int maxWidth, + int maxHeight); + + QByteArray m_bytes; + std::unique_ptr m_buffer; + std::unique_ptr m_reader; + QString m_token; + int m_frameCount = 0; + int m_nextFrame = 0; + int m_maxWidth = 0; + int m_maxHeight = 0; +}; + +} // namespace ImageProbe diff --git a/src/preview/PreviewProtocol.hpp b/src/preview/PreviewProtocol.hpp index 052d59b..b8866cb 100644 --- a/src/preview/PreviewProtocol.hpp +++ b/src/preview/PreviewProtocol.hpp @@ -38,4 +38,17 @@ inline constexpr int kMaxFontPixelSize = 96; inline constexpr int kMaxFontSampleChars = 96; inline constexpr int kMaxFontWritingSystems = 16; +inline constexpr qint64 kMaxImageMetadataInputBytes = 64LL * 1024 * 1024; +inline constexpr int kMaxImageMetadataChars = 256; +inline constexpr qint64 kMaxAnimationInputBytes = 64LL * 1024 * 1024; +inline constexpr int kMaxAnimationSourceDimension = 4096; +inline constexpr qint64 kMaxAnimationSourcePixels = 16LL * 1024 * 1024; +inline constexpr int kDefaultAnimationFrameWidth = 960; +inline constexpr int kDefaultAnimationFrameHeight = 720; +inline constexpr int kMaxAnimationFrameDimension = 1024; +inline constexpr int kMaxAnimationFrames = 120; +inline constexpr int kMinAnimationDelayMs = 50; +inline constexpr int kMaxAnimationDelayMs = 500; +inline constexpr int kMaxAnimationPlayMs = 30'000; + } // namespace PreviewProtocol diff --git a/src/preview/TextPreviewLoader.hpp b/src/preview/TextPreviewLoader.hpp index 436f87c..00e83c6 100644 --- a/src/preview/TextPreviewLoader.hpp +++ b/src/preview/TextPreviewLoader.hpp @@ -4,6 +4,8 @@ #include "ArchivePreviewStore.hpp" #include "TextPreviewStore.hpp" +#include +#include #include #include #include @@ -254,3 +256,169 @@ class FontPreviewLoader : public QObject { QString m_error; QTimer m_debounce; }; + +class ImagePreviewLoader : public QObject { + Q_OBJECT + + Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged) + Q_PROPERTY(bool active READ active WRITE setActive NOTIFY activeChanged) + Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged) + Q_PROPERTY(bool supported READ supported NOTIFY resultChanged) + Q_PROPERTY(int pixelWidth READ pixelWidth NOTIFY resultChanged) + Q_PROPERTY(int pixelHeight READ pixelHeight NOTIFY resultChanged) + Q_PROPERTY(QString formatName READ formatName NOTIFY resultChanged) + Q_PROPERTY(bool metadataAvailable READ metadataAvailable NOTIFY resultChanged) + Q_PROPERTY(bool metadataLimited READ metadataLimited NOTIFY resultChanged) + Q_PROPERTY(bool animated READ animated NOTIFY resultChanged) + Q_PROPERTY(bool animationSupported READ animationSupported NOTIFY resultChanged) + Q_PROPERTY(int frameCount READ frameCount NOTIFY resultChanged) + Q_PROPERTY(QString cameraMake READ cameraMake NOTIFY resultChanged) + Q_PROPERTY(QString cameraModel READ cameraModel NOTIFY resultChanged) + Q_PROPERTY(QString lensModel READ lensModel NOTIFY resultChanged) + Q_PROPERTY(QString dateTaken READ dateTaken NOTIFY resultChanged) + Q_PROPERTY(QString exposureTime READ exposureTime NOTIFY resultChanged) + Q_PROPERTY(QString aperture READ aperture NOTIFY resultChanged) + Q_PROPERTY(QString iso READ iso NOTIFY resultChanged) + Q_PROPERTY(QString focalLength READ focalLength NOTIFY resultChanged) + Q_PROPERTY(QString orientation READ orientation NOTIFY resultChanged) + Q_PROPERTY(QString exposureBias READ exposureBias NOTIFY resultChanged) + Q_PROPERTY(QString whiteBalance READ whiteBalance NOTIFY resultChanged) + Q_PROPERTY(QString colorSpace READ colorSpace NOTIFY resultChanged) + Q_PROPERTY(QString software READ software NOTIFY resultChanged) + Q_PROPERTY(QString artist READ artist NOTIFY resultChanged) + Q_PROPERTY(QString copyright READ copyright NOTIFY resultChanged) + Q_PROPERTY(QString error READ error NOTIFY resultChanged) + +public: + explicit ImagePreviewLoader(QObject* parent = nullptr); + ~ImagePreviewLoader() override; + + QString path() const { return m_path; } + void setPath(const QString& path); + bool active() const { return m_active; } + void setActive(bool active); + bool loading() const { return m_loading; } + bool supported() const { return m_supported; } + int pixelWidth() const { return m_pixelWidth; } + int pixelHeight() const { return m_pixelHeight; } + QString formatName() const { return m_formatName; } + bool metadataAvailable() const { return m_metadataAvailable; } + bool metadataLimited() const { return m_metadataLimited; } + bool animated() const { return m_animated; } + bool animationSupported() const { return m_animationSupported; } + int frameCount() const { return m_frameCount; } + QString cameraMake() const { return m_cameraMake; } + QString cameraModel() const { return m_cameraModel; } + QString lensModel() const { return m_lensModel; } + QString dateTaken() const { return m_dateTaken; } + QString exposureTime() const { return m_exposureTime; } + QString aperture() const { return m_aperture; } + QString iso() const { return m_iso; } + QString focalLength() const { return m_focalLength; } + QString orientation() const { return m_orientation; } + QString exposureBias() const { return m_exposureBias; } + QString whiteBalance() const { return m_whiteBalance; } + QString colorSpace() const { return m_colorSpace; } + QString software() const { return m_software; } + QString artist() const { return m_artist; } + QString copyright() const { return m_copyright; } + QString error() const { return m_error; } + + Q_INVOKABLE bool isCandidate(const QString& path) const; + +signals: + void pathChanged(); + void activeChanged(); + void loadingChanged(); + void resultChanged(); + +private: + void scheduleLoad(); + void startLoad(); + void clearResult(); + void setLoading(bool loading); + + QString m_path; + bool m_active = false; + bool m_loading = false; + bool m_supported = false; + int m_pixelWidth = 0; + int m_pixelHeight = 0; + bool m_metadataAvailable = false; + bool m_metadataLimited = false; + bool m_animated = false; + bool m_animationSupported = false; + int m_frameCount = 0; + quint64 m_generation = 0; + QString m_formatName; + QString m_cameraMake; + QString m_cameraModel; + QString m_lensModel; + QString m_dateTaken; + QString m_exposureTime; + QString m_aperture; + QString m_iso; + QString m_focalLength; + QString m_orientation; + QString m_exposureBias; + QString m_whiteBalance; + QString m_colorSpace; + QString m_software; + QString m_artist; + QString m_copyright; + QString m_error; + QTimer m_debounce; +}; + +class ImageAnimationController : public QObject { + Q_OBJECT + + Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged) + Q_PROPERTY(bool active READ active WRITE setActive NOTIFY activeChanged) + Q_PROPERTY(bool playing READ playing NOTIFY playbackChanged) + Q_PROPERTY(QString frameSource READ frameSource NOTIFY playbackChanged) + Q_PROPERTY(int frame READ frame NOTIFY playbackChanged) + Q_PROPERTY(int frameCount READ frameCount NOTIFY playbackChanged) + Q_PROPERTY(QString error READ error NOTIFY playbackChanged) + +public: + explicit ImageAnimationController(QObject* parent = nullptr); + ~ImageAnimationController() override; + + QString path() const { return m_path; } + void setPath(const QString& path); + bool active() const { return m_active; } + void setActive(bool active); + bool playing() const { return m_playing; } + QString frameSource() const { return m_frameSource; } + int frame() const { return m_frame; } + int frameCount() const { return m_frameCount; } + QString error() const { return m_error; } + + Q_INVOKABLE void play(); + Q_INVOKABLE void stop(); + +signals: + void pathChanged(); + void activeChanged(); + void playbackChanged(); + +private: + void requestStart(); + void requestNext(); + void applyFrame(const QJsonObject& payload); + void fail(const QString& error); + void stopInternal(); + + QString m_path; + bool m_active = false; + bool m_playing = false; + quint64 m_generation = 0; + QString m_session; + QString m_frameSource; + int m_frame = -1; + int m_frameCount = 0; + QString m_error; + QTimer m_frameTimer; + QElapsedTimer m_elapsed; +}; diff --git a/src/preview/preview_helper_main.cpp b/src/preview/preview_helper_main.cpp index 991e450..f43e3cf 100644 --- a/src/preview/preview_helper_main.cpp +++ b/src/preview/preview_helper_main.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-only #include "FontProbe.hpp" +#include "ImageProbe.hpp" #include "MediaProbe.hpp" #include "PreviewFileAccess.hpp" #include "PreviewProtocol.hpp" @@ -28,6 +29,8 @@ namespace { +std::unique_ptr g_imageAnimationSession; + QJsonObject response( const QString& id, bool ok, @@ -255,12 +258,37 @@ QJsonObject handleRequest(const QJsonObject& request) { return response({}, false, QStringLiteral("Preview request is missing an id")); const QString op = request.value(QStringLiteral("op")).toString(); + +#ifdef Q_OS_UNIX + QString error; + + if (op == QStringLiteral("image-animation-next")) { + const QString session = request.value(QStringLiteral("session")).toString(); + if (!g_imageAnimationSession || session.isEmpty() + || session != g_imageAnimationSession->token()) { + return response(id, false, QStringLiteral("Animation session is not active")); + } + + const QJsonObject payload = g_imageAnimationSession->readNext(&error); + if (!error.isEmpty()) { + g_imageAnimationSession.reset(); + return response(id, false, error); + } + + const int frame = payload.value(QStringLiteral("frame")).toInt(-1); + const int frameCount = payload.value(QStringLiteral("frameCount")).toInt(0); + if (frame >= 0 && frameCount > 0 && frame + 1 >= frameCount) + g_imageAnimationSession.reset(); + return response(id, true, {}, payload); + } + const QString path = request.value(QStringLiteral("path")).toString(); if (path.isEmpty() || path.contains(QChar::Null) || !QFileInfo(path).isAbsolute()) return response(id, false, QStringLiteral("Preview path must be an absolute local path")); -#ifdef Q_OS_UNIX - QString error; + if (op != QStringLiteral("image-animation-start")) + g_imageAnimationSession.reset(); + if (op == QStringLiteral("stat")) { const QJsonObject payload = statPayload(path, &error); if (!error.isEmpty()) @@ -307,9 +335,52 @@ QJsonObject handleRequest(const QJsonObject& request) { return response(id, true, {}, payload); } + if (op == QStringLiteral("image-probe")) { + PreviewFileAccess::OpenedFile opened = + PreviewFileAccess::openRegularNoFollow(path, &error); + if (!opened) + return response(id, false, error); + + const QJsonObject payload = ImageProbe::probe( + *opened.file, + opened.size, + request, + &error); + if (!error.isEmpty()) + return response(id, false, error); + return response(id, true, {}, payload); + } + + if (op == QStringLiteral("image-animation-start")) { + g_imageAnimationSession.reset(); + + PreviewFileAccess::OpenedFile opened = + PreviewFileAccess::openRegularNoFollow(path, &error); + if (!opened) + return response(id, false, error); + + auto session = ImageProbe::AnimationSession::create( + std::move(opened), + request, + &error); + if (!session || !error.isEmpty()) + return response( + id, + false, + error.isEmpty() + ? QStringLiteral("Could not start animation preview") + : error); + + const QJsonObject payload = session->readNext(&error); + if (!error.isEmpty()) + return response(id, false, error); + + g_imageAnimationSession = std::move(session); + return response(id, true, {}, payload); + } + return response(id, false, QStringLiteral("Unsupported preview operation")); #else - Q_UNUSED(path); Q_UNUSED(op); return response(id, false, QStringLiteral("Preview helper currently requires Unix no-follow file APIs")); #endif diff --git a/tests/ImagePreviewTest.cpp b/tests/ImagePreviewTest.cpp new file mode 100644 index 0000000..46994a3 --- /dev/null +++ b/tests/ImagePreviewTest.cpp @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: GPL-3.0-only + +#include "preview/PreviewProtocol.hpp" +#include "preview/PreviewScheduler.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +class ImagePreviewTest final : public QObject { + Q_OBJECT + +private: + static QString helperPath() { + return QDir(QCoreApplication::applicationDirPath()) + .filePath(QStringLiteral("ryofiles-preview-helper")); + } + + static PreviewResult execute( + PreviewScheduler& scheduler, + PreviewScheduler::Lane lane, + QObject& owner, + const QJsonObject& request) { + PreviewResult result; + result.error = QStringLiteral("Image preview test timed out"); + bool completed = false; + QEventLoop loop; + + const bool submitted = scheduler.submit( + lane, + &owner, + request, + [&result, &completed, &loop](PreviewResult value) { + result = std::move(value); + completed = true; + loop.quit(); + }); + if (!submitted) { + result.error = QStringLiteral("Image preview request was rejected by scheduler"); + return result; + } + + QTimer::singleShot(5000, &loop, &QEventLoop::quit); + loop.exec(); + if (!completed) + scheduler.cancelOwner(&owner); + return result; + } + + static QByteArray jpegFixture() { + return QByteArray::fromBase64( + QByteArrayLiteral("/9j/4AAQSkZJRgABAQAAAQABAAD/4QEeRXhpZgAATU0AKgAAAAgABgEPAAIAAAAQAAAAVgEQAAIAAAAIAAAAZgESAAMAAAABAAYAAAExAAIAAAAOAAAAbgEyAAIAAAAUAAAAfIdpAAQAAAABAAAAkAAAAABSeW9maWxlcyBDYW1lcmEATW9kZWwgWABSeW9maWxlcyBUZXN0ADIwMjY6MDk6MDYgMTQ6MDA6MDAAAAaCmgAFAAAAAQAAAN6CnQAFAAAAAQAAAOaIJwADAAAAAQDIAACQAwACAAAAFAAAAO6SCgAFAAAAAQAAAQKkNAACAAAADAAAAQoAAAAAAAAAAQAAAH0AAAAcAAAACjIwMjY6MDk6MDYgMTM6NTk6NTgAAAAAMgAAAAFSeW8gTGVucyA1MAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAADAAQDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDlqKKK8c9Q/9k=")); + } + + static QByteArray gifFixture() { + return QByteArray::fromBase64( + QByteArrayLiteral("R0lGODlhAgACAIEAAP8AAAAAAAAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQACgAAACwAAAAAAgACAAAIBgABCAQQEAAh+QQBDAABACwAAAAAAgACAIEA/wAAAAAAAAAAAAAIBgABCAQQEAA7")); + } + +private slots: + void imageBoundsAreFrozen() { + QCOMPARE(PreviewProtocol::kMaxImageMetadataInputBytes, 64LL * 1024 * 1024); + QCOMPARE(PreviewProtocol::kMaxImageMetadataChars, 256); + QCOMPARE(PreviewProtocol::kMaxAnimationInputBytes, 64LL * 1024 * 1024); + QCOMPARE(PreviewProtocol::kMaxAnimationSourceDimension, 4096); + QCOMPARE(PreviewProtocol::kMaxAnimationFrames, 120); + QCOMPARE(PreviewProtocol::kMinAnimationDelayMs, 50); + QCOMPARE(PreviewProtocol::kMaxAnimationPlayMs, 30'000); + } + + void readsExifWithoutPathReopen() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = directory.filePath(QStringLiteral("fixture.jpg")); + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly)); + QCOMPARE(file.write(jpegFixture()), jpegFixture().size()); + file.close(); + + PreviewScheduler scheduler(helperPath()); + QObject owner; + const PreviewResult result = execute( + scheduler, + PreviewScheduler::Lane::InteractivePreview, + owner, + QJsonObject { + {QStringLiteral("op"), QStringLiteral("image-probe")}, + {QStringLiteral("path"), path}, + }); + + QVERIFY2(result.ok, qPrintable(result.error)); + QCOMPARE(result.payload.value(QStringLiteral("pixelWidth")).toInt(), 4); + QCOMPARE(result.payload.value(QStringLiteral("pixelHeight")).toInt(), 3); + QCOMPARE(result.payload.value(QStringLiteral("cameraMake")).toString(), QStringLiteral("Ryofiles Camera")); + QCOMPARE(result.payload.value(QStringLiteral("cameraModel")).toString(), QStringLiteral("Model X")); + QCOMPARE(result.payload.value(QStringLiteral("lensModel")).toString(), QStringLiteral("Ryo Lens 50")); + QCOMPARE(result.payload.value(QStringLiteral("iso")).toString(), QStringLiteral("200")); + QVERIFY(result.payload.value(QStringLiteral("metadataAvailable")).toBool()); + } + + void refusesImageSymlink() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString source = directory.filePath(QStringLiteral("source.jpg")); + QFile file(source); + QVERIFY(file.open(QIODevice::WriteOnly)); + QCOMPARE(file.write(jpegFixture()), jpegFixture().size()); + file.close(); + const QString link = directory.filePath(QStringLiteral("linked.jpg")); + QVERIFY(QFile::link(source, link)); + + PreviewScheduler scheduler(helperPath()); + QObject owner; + const PreviewResult result = execute( + scheduler, + PreviewScheduler::Lane::InteractivePreview, + owner, + QJsonObject { + {QStringLiteral("op"), QStringLiteral("image-probe")}, + {QStringLiteral("path"), link}, + }); + + QVERIFY(!result.ok); + QVERIFY(result.error.contains(QStringLiteral("regular file"))); + } + + void skipsDeepMetadataPastBound() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = directory.filePath(QStringLiteral("large.jpg")); + QFile file(path); + QVERIFY(file.open(QIODevice::ReadWrite)); + QCOMPARE(file.write(jpegFixture()), jpegFixture().size()); + QVERIFY(file.resize(PreviewProtocol::kMaxImageMetadataInputBytes + 1)); + file.close(); + + PreviewScheduler scheduler(helperPath()); + QObject owner; + const PreviewResult result = execute( + scheduler, + PreviewScheduler::Lane::InteractivePreview, + owner, + QJsonObject { + {QStringLiteral("op"), QStringLiteral("image-probe")}, + {QStringLiteral("path"), path}, + }); + + QVERIFY2(result.ok, qPrintable(result.error)); + QVERIFY(result.payload.value(QStringLiteral("metadataLimited")).toBool()); + QVERIFY(!result.payload.value(QStringLiteral("metadataAvailable")).toBool()); + } + + void animatedGifRequiresExplicitLongWorkRequests() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = directory.filePath(QStringLiteral("two-frame.gif")); + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly)); + QCOMPARE(file.write(gifFixture()), gifFixture().size()); + file.close(); + + PreviewScheduler scheduler(helperPath()); + QObject probeOwner; + const PreviewResult probe = execute( + scheduler, + PreviewScheduler::Lane::InteractivePreview, + probeOwner, + QJsonObject { + {QStringLiteral("op"), QStringLiteral("image-probe")}, + {QStringLiteral("path"), path}, + }); + QVERIFY2(probe.ok, qPrintable(probe.error)); + QVERIFY(probe.payload.value(QStringLiteral("animated")).toBool()); + QVERIFY(probe.payload.value(QStringLiteral("animationSupported")).toBool()); + QCOMPARE(probe.payload.value(QStringLiteral("frameCount")).toInt(), 2); + + QObject animationOwner; + const QString session = QStringLiteral("image-test-session"); + const PreviewResult first = execute( + scheduler, + PreviewScheduler::Lane::ExplicitLongWork, + animationOwner, + QJsonObject { + {QStringLiteral("op"), QStringLiteral("image-animation-start")}, + {QStringLiteral("path"), path}, + {QStringLiteral("session"), session}, + {QStringLiteral("maxWidth"), 640}, + {QStringLiteral("maxHeight"), 480}, + }); + QVERIFY2(first.ok, qPrintable(first.error)); + QCOMPARE(first.payload.value(QStringLiteral("session")).toString(), session); + QCOMPARE(first.payload.value(QStringLiteral("frame")).toInt(), 0); + QCOMPARE(first.payload.value(QStringLiteral("frameCount")).toInt(), 2); + QVERIFY(!first.payload.value(QStringLiteral("imageBase64")).toString().isEmpty()); + + const PreviewResult second = execute( + scheduler, + PreviewScheduler::Lane::ExplicitLongWork, + animationOwner, + QJsonObject { + {QStringLiteral("op"), QStringLiteral("image-animation-next")}, + {QStringLiteral("session"), session}, + }); + QVERIFY2(second.ok, qPrintable(second.error)); + QCOMPARE(second.payload.value(QStringLiteral("frame")).toInt(), 1); + QCOMPARE(second.payload.value(QStringLiteral("frameCount")).toInt(), 2); + QVERIFY(!second.payload.value(QStringLiteral("imageBase64")).toString().isEmpty()); + } +}; + +QTEST_GUILESS_MAIN(ImagePreviewTest) +#include "ImagePreviewTest.moc" diff --git a/tools/preview/CMakeLists.txt b/tools/preview/CMakeLists.txt index 4024502..ca6132e 100644 --- a/tools/preview/CMakeLists.txt +++ b/tools/preview/CMakeLists.txt @@ -16,12 +16,15 @@ pkg_check_modules(AVFORMAT REQUIRED IMPORTED_TARGET libavformat) pkg_check_modules(AVCODEC REQUIRED IMPORTED_TARGET libavcodec) pkg_check_modules(AVUTIL REQUIRED IMPORTED_TARGET libavutil) pkg_check_modules(SWSCALE REQUIRED IMPORTED_TARGET libswscale) +pkg_check_modules(EXIV2 REQUIRED IMPORTED_TARGET exiv2) qt_add_executable(ryofiles-preview-helper ../../src/preview/PreviewProtocol.hpp ../../src/preview/PreviewFileAccess.hpp ../../src/preview/FontProbe.hpp ../../src/preview/FontProbe.cpp + ../../src/preview/ImageProbe.hpp + ../../src/preview/ImageProbe.cpp ../../src/preview/MediaProbe.hpp ../../src/preview/MediaProbe.cpp ../../src/preview/preview_helper_main.cpp @@ -35,6 +38,7 @@ target_link_libraries(ryofiles-preview-helper PRIVATE PkgConfig::AVCODEC PkgConfig::AVUTIL PkgConfig::SWSCALE + PkgConfig::EXIV2 ) option(RYOFILES_PREVIEW_BUILD_TESTS "Build preview foundation tests" ON) @@ -76,6 +80,18 @@ if(RYOFILES_PREVIEW_BUILD_TESTS) add_dependencies(ryofiles_font_preview_tests ryofiles-preview-helper) add_test(NAME font-preview COMMAND ryofiles_font_preview_tests) + + qt_add_executable(ryofiles_image_preview_tests + ../../tests/ImagePreviewTest.cpp + ../../src/preview/PreviewProtocol.hpp + ../../src/preview/PreviewScheduler.hpp + ../../src/preview/PreviewScheduler.cpp + ) + target_include_directories(ryofiles_image_preview_tests PRIVATE ../../src) + target_link_libraries(ryofiles_image_preview_tests PRIVATE Qt6::Core Qt6::Test) + add_dependencies(ryofiles_image_preview_tests ryofiles-preview-helper) + + add_test(NAME image-preview COMMAND ryofiles_image_preview_tests) endif() install(TARGETS ryofiles-preview-helper