diff options
-rw-r--r-- | core/CMakeLists.txt | 1 | ||||
-rw-r--r-- | core/imagedownloader.cpp | 123 | ||||
-rw-r--r-- | core/imagedownloader.h | 11 | ||||
-rw-r--r-- | core/pref.h | 3 | ||||
-rw-r--r-- | core/subsurface-qt/SettingsObjectWrapper.cpp | 52 | ||||
-rw-r--r-- | core/subsurface-qt/SettingsObjectWrapper.h | 28 | ||||
-rw-r--r-- | core/subsurfacestartup.c | 4 | ||||
-rw-r--r-- | core/videoframeextractor.cpp | 120 | ||||
-rw-r--r-- | core/videoframeextractor.h | 37 | ||||
-rw-r--r-- | desktop-widgets/preferences/preferences_defaults.cpp | 27 | ||||
-rw-r--r-- | desktop-widgets/preferences/preferences_defaults.h | 2 | ||||
-rw-r--r-- | desktop-widgets/preferences/preferences_defaults.ui | 75 | ||||
-rw-r--r-- | icons/video_overlay.svg | 263 | ||||
-rw-r--r-- | subsurface.qrc | 1 |
14 files changed, 727 insertions, 20 deletions
diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index dbaa3bbe5..416c1ea5b 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -97,6 +97,7 @@ set(SUBSURFACE_CORE_LIB_SRCS uemis.c uemis-downloader.c version.c + videoframeextractor.cpp windowtitleupdate.cpp worldmap-save.c diff --git a/core/imagedownloader.cpp b/core/imagedownloader.cpp index 7dc35695d..d78409a3c 100644 --- a/core/imagedownloader.cpp +++ b/core/imagedownloader.cpp @@ -4,6 +4,7 @@ #include "divelist.h" #include "qthelper.h" #include "imagedownloader.h" +#include "videoframeextractor.h" #include "qt-models/divepicturemodel.h" #include "metadata.h" #include <unistd.h> @@ -96,7 +97,7 @@ Thumbnailer::Thumbnail Thumbnailer::fetchImage(const QString &filename, const QS if (type == MEDIATYPE_IO_ERROR) return { failImage, MEDIATYPE_IO_ERROR, 0 }; else if (type == MEDIATYPE_VIDEO) - return addVideoThumbnailToCache(originalFilename, md.duration); + return fetchVideoThumbnail(filename, originalFilename, md.duration); // Try if Qt can parse this image. If it does, use this as a thumbnail. QImage thumb(filename); @@ -110,7 +111,7 @@ Thumbnailer::Thumbnail Thumbnailer::fetchImage(const QString &filename, const QS // Try to check for a video-file extension. Since we couldn't parse the video file, // we pass 0 as the duration. if (hasVideoFileExtension(filename)) - return addVideoThumbnailToCache(originalFilename, {0} ); + return fetchVideoThumbnail(filename, originalFilename, {0} ); // Give up: we simply couldn't determine what this thing is. // But since we managed to read this file, mark this file in the cache as unknown. @@ -163,9 +164,22 @@ static QImage renderIcon(const char *id, int size) return res; } +// As renderIcon, but render to a fixed width and scale height accordingly +// and have a transparent background. +static QImage renderIconWidth(const char *id, int size) +{ + QSvgRenderer svg{QString(id)}; + QSize svgSize = svg.defaultSize(); + QImage res(size, size * svgSize.height() / svgSize.width(), QImage::Format_ARGB32); + QPainter painter(&res); + svg.render(&painter); + return res; +} + Thumbnailer::Thumbnailer() : failImage(renderIcon(":filter-close", maxThumbnailSize())), // TODO: Don't misuse filter close icon dummyImage(renderIcon(":camera-icon", maxThumbnailSize())), videoImage(renderIcon(":video-icon", maxThumbnailSize())), + videoOverlayImage(renderIconWidth(":video-overlay", maxThumbnailSize())), unknownImage(renderIcon(":unknown-icon", maxThumbnailSize())) { // Currently, we only process one image at a time. Stefan Fuchs reported problems when @@ -173,6 +187,9 @@ Thumbnailer::Thumbnailer() : failImage(renderIcon(":filter-close", maxThumbnailS pool.setMaxThreadCount(1); connect(ImageDownloader::instance(), &ImageDownloader::loaded, this, &Thumbnailer::imageDownloaded); connect(ImageDownloader::instance(), &ImageDownloader::failed, this, &Thumbnailer::imageDownloadFailed); + connect(VideoFrameExtractor::instance(), &VideoFrameExtractor::extracted, this, &Thumbnailer::frameExtracted); + connect(VideoFrameExtractor::instance(), &VideoFrameExtractor::failed, this, &Thumbnailer::frameExtractionFailed); + connect(VideoFrameExtractor::instance(), &VideoFrameExtractor::failed, this, &Thumbnailer::frameExtractionInvalid); } Thumbnailer *Thumbnailer::instance() @@ -188,7 +205,17 @@ Thumbnailer::Thumbnail Thumbnailer::getPictureThumbnailFromStream(QDataStream &s return { res, MEDIATYPE_PICTURE, 0 }; } -Thumbnailer::Thumbnail Thumbnailer::getVideoThumbnailFromStream(QDataStream &stream) +void Thumbnailer::markVideoThumbnail(QImage &img) +{ + QSize size = img.size(); + QImage marker = videoOverlayImage.scaledToWidth(size.width()); + marker = marker.copy(0, (marker.size().height() - size.height()) / 2, size.width(), size.height()); + QPainter painter(&img); + painter.drawImage(0, 0, marker); +} + +Q_DECLARE_METATYPE(duration_t) +Thumbnailer::Thumbnail Thumbnailer::getVideoThumbnailFromStream(QDataStream &stream, const QString &filename) { quint32 duration, numPics; stream >> duration >> numPics; @@ -200,16 +227,27 @@ Thumbnailer::Thumbnail Thumbnailer::getVideoThumbnailFromStream(QDataStream &str if (stream.status() != QDataStream::Ok || duration > 36000 || numPics > 10000) return { QImage(), MEDIATYPE_VIDEO, 0 }; + // If the file didn't contain an image, but user turned on thumbnail extraction, schedule thumbnail + // for extraction. TODO: save failure to extract thumbnails to disk so that thumbnailing + // is not repeated ad-nauseum for broken images. + if (numPics == 0 && prefs.extract_video_thumbnails) { + QMetaObject::invokeMethod(VideoFrameExtractor::instance(), "extract", Qt::AutoConnection, + Q_ARG(QString, filename), Q_ARG(QString, filename), Q_ARG(duration_t, duration_t{(int32_t)duration})); + } + // Currently, we support only one picture QImage res; if (numPics > 0) { quint32 offset; - QImage res; stream >> offset >> res; } - // No picture -> show dummy-icon - return { res.isNull() ? videoImage : res, MEDIATYPE_VIDEO, (int32_t)duration }; + if (res.isNull()) + res = videoImage; // No picture -> show dummy-icon + else + markVideoThumbnail(res); // We got an image -> place our video marker on top of it + + return { res, MEDIATYPE_VIDEO, (int32_t)duration }; } // Fetch a thumbnail from cache. @@ -248,13 +286,14 @@ Thumbnailer::Thumbnail Thumbnailer::getThumbnailFromCache(const QString &picture switch (type) { case MEDIATYPE_PICTURE: return getPictureThumbnailFromStream(stream); - case MEDIATYPE_VIDEO: return getVideoThumbnailFromStream(stream); + case MEDIATYPE_VIDEO: return getVideoThumbnailFromStream(stream, picture_filename); case MEDIATYPE_UNKNOWN: return { unknownImage, MEDIATYPE_UNKNOWN, 0 }; default: return { QImage(), MEDIATYPE_UNKNOWN, 0 }; } } -Thumbnailer::Thumbnail Thumbnailer::addVideoThumbnailToCache(const QString &picture_filename, duration_t duration) +Thumbnailer::Thumbnail Thumbnailer::addVideoThumbnailToCache(const QString &picture_filename, duration_t duration, + const QImage &image, duration_t position) { // The format of video thumbnails: // uint32 MEDIATYPE_VIDEO @@ -270,12 +309,36 @@ Thumbnailer::Thumbnail Thumbnailer::addVideoThumbnailToCache(const QString &pict stream << (quint32)MEDIATYPE_VIDEO; stream << (quint32)duration.seconds; - stream << (quint32)0; // Currently, we don't support extraction of images + + if (image.isNull()) { + // No image provided + stream << (quint32)0; + } else { + // Currently, we support at most one image + stream << (quint32)1; + stream << (quint32)position.seconds; + stream << image; + } + file.commit(); } return { videoImage, MEDIATYPE_VIDEO, duration }; } +Thumbnailer::Thumbnail Thumbnailer::fetchVideoThumbnail(const QString &filename, const QString &originalFilename, duration_t duration) +{ + if (prefs.extract_video_thumbnails) { + // Video-thumbnailing is enabled. Fetch thumbnail in background thread and in the meanwhile + // return a dummy image. + QMetaObject::invokeMethod(VideoFrameExtractor::instance(), "extract", Qt::AutoConnection, + Q_ARG(QString, originalFilename), Q_ARG(QString, filename), Q_ARG(duration_t, duration)); + return { videoImage, MEDIATYPE_VIDEO, duration }; + } else { + // Video-thumbnailing is disabled. Write a thumbnail without picture. + return addVideoThumbnailToCache(originalFilename, duration, QImage(), {0}); + } +} + Thumbnailer::Thumbnail Thumbnailer::addPictureThumbnailToCache(const QString &picture_filename, const QImage &thumbnail) { // The format of a picture-thumbnail is very simple: @@ -304,6 +367,44 @@ Thumbnailer::Thumbnail Thumbnailer::addUnknownThumbnailToCache(const QString &pi return { unknownImage, MEDIATYPE_UNKNOWN, 0 }; } +void Thumbnailer::frameExtracted(QString filename, QImage thumbnail, duration_t duration, duration_t offset) +{ + if (thumbnail.isNull()) { + frameExtractionFailed(filename, duration); + return; + } else { + int size = maxThumbnailSize(); + thumbnail = thumbnail.scaled(size, size, Qt::KeepAspectRatio); + markVideoThumbnail(thumbnail); + addVideoThumbnailToCache(filename, duration, thumbnail, offset); + QMutexLocker l(&lock); + workingOn.remove(filename); + emit thumbnailChanged(filename, thumbnail, duration); + } +} + +// If frame extraction failed, don't show an error image, because we don't want +// to penalize users that haven't installed ffmpe. Simply remove this item from +// the work-queue. +void Thumbnailer::frameExtractionFailed(QString filename, duration_t duration) +{ + // Frame extraction failed, but this was due to ffmpeg not starting + // add to the thumbnail cache as a video image with unknown thumbnail. + addVideoThumbnailToCache(filename, duration, QImage(), { 0 }); + QMutexLocker l(&lock); + workingOn.remove(filename); +} + +void Thumbnailer::frameExtractionInvalid(QString filename, duration_t) +{ + // Frame extraction failed because ffmpeg could not parse the file. + // For now, let's mark this as an unknown file. The user may want + // to recalculate thumbnails with an updated ffmpeg binary..? + addUnknownThumbnailToCache(filename); + QMutexLocker l(&lock); + workingOn.remove(filename); +} + void Thumbnailer::recalculate(QString filename) { Thumbnail thumbnail = getHashedImage(filename, true); @@ -380,6 +481,10 @@ void Thumbnailer::calculateThumbnails(const QVector<QString> &filenames) void Thumbnailer::clearWorkQueue() { + // We also want to clear the working-queue of the video-frame-extractor so that + // we don't get thumbnails that we don't care about. + VideoFrameExtractor::instance()->clearWorkQueue(); + QMutexLocker l(&lock); for (auto it = workingOn.begin(); it != workingOn.end(); ++it) it->cancel(); diff --git a/core/imagedownloader.h b/core/imagedownloader.h index cab945e5f..e0d70f59f 100644 --- a/core/imagedownloader.h +++ b/core/imagedownloader.h @@ -46,6 +46,9 @@ public: public slots: void imageDownloaded(QString filename); void imageDownloadFailed(QString filename); + void frameExtracted(QString filename, QImage thumbnail, duration_t duration, duration_t offset); + void frameExtractionFailed(QString filename, duration_t duration); + void frameExtractionInvalid(QString filename, duration_t duration); signals: void thumbnailChanged(QString filename, QImage thumbnail, duration_t duration); private: @@ -56,22 +59,26 @@ private: }; Thumbnailer(); + Thumbnail fetchVideoThumbnail(const QString &filename, const QString &originalFilename, duration_t duration); + Thumbnail extractVideoThumbnail(const QString &picture_filename, duration_t duration); Thumbnail addPictureThumbnailToCache(const QString &picture_filename, const QImage &thumbnail); - Thumbnail addVideoThumbnailToCache(const QString &picture_filename, duration_t duration); + Thumbnail addVideoThumbnailToCache(const QString &picture_filename, duration_t duration, const QImage &thumbnail, duration_t position); Thumbnail addUnknownThumbnailToCache(const QString &picture_filename); void recalculate(QString filename); void processItem(QString filename, bool tryDownload); Thumbnail getThumbnailFromCache(const QString &picture_filename); Thumbnail getPictureThumbnailFromStream(QDataStream &stream); - Thumbnail getVideoThumbnailFromStream(QDataStream &stream); + Thumbnail getVideoThumbnailFromStream(QDataStream &stream, const QString &filename); Thumbnail fetchImage(const QString &filename, const QString &originalFilename, bool tryDownload); Thumbnail getHashedImage(const QString &filename, bool tryDownload); + void markVideoThumbnail(QImage &img); mutable QMutex lock; QThreadPool pool; QImage failImage; // Shown when image-fetching fails QImage dummyImage; // Shown before thumbnail is fetched QImage videoImage; // Place holder for videos + QImage videoOverlayImage; // Overlay for video thumbnails QImage unknownImage; // Place holder for files where we couldn't determine the type QMap<QString,QFuture<void>> workingOn; diff --git a/core/pref.h b/core/pref.h index bae7992fe..a037a2da5 100644 --- a/core/pref.h +++ b/core/pref.h @@ -103,6 +103,9 @@ struct preferences { // ********** General ********** bool auto_recalculate_thumbnails; + bool extract_video_thumbnails; + int extract_video_thumbnails_position; // position in stream: 0=first 100=last second + const char *ffmpeg_executable; // path of ffmpeg binary int defaultsetpoint; // default setpoint in mbar const char *default_cylinder; const char *default_filename; diff --git a/core/subsurface-qt/SettingsObjectWrapper.cpp b/core/subsurface-qt/SettingsObjectWrapper.cpp index 3085216c7..da2a86a68 100644 --- a/core/subsurface-qt/SettingsObjectWrapper.cpp +++ b/core/subsurface-qt/SettingsObjectWrapper.cpp @@ -1474,6 +1474,21 @@ bool GeneralSettingsObjectWrapper::autoRecalculateThumbnails() const return prefs.auto_recalculate_thumbnails; } +bool GeneralSettingsObjectWrapper::extractVideoThumbnails() const +{ + return prefs.extract_video_thumbnails; +} + +int GeneralSettingsObjectWrapper::extractVideoThumbnailsPosition() const +{ + return prefs.extract_video_thumbnails_position; +} + +QString GeneralSettingsObjectWrapper::ffmpegExecutable() const +{ + return prefs.ffmpeg_executable; +} + void GeneralSettingsObjectWrapper::setDefaultFilename(const QString& value) { if (value == prefs.default_filename) @@ -1579,6 +1594,43 @@ void GeneralSettingsObjectWrapper::setAutoRecalculateThumbnails(bool value) emit autoRecalculateThumbnailsChanged(value); } +void GeneralSettingsObjectWrapper::setExtractVideoThumbnails(bool value) +{ + if (value == prefs.extract_video_thumbnails) + return; + + QSettings s; + s.beginGroup(group); + s.setValue("extract_video_thumbnails", value); + prefs.extract_video_thumbnails = value; + emit extractVideoThumbnailsChanged(value); +} + +void GeneralSettingsObjectWrapper::setExtractVideoThumbnailsPosition(int value) +{ + if (value == prefs.extract_video_thumbnails_position) + return; + + QSettings s; + s.beginGroup(group); + s.setValue("extract_video_thumbnails_position", value); + prefs.extract_video_thumbnails_position = value; + emit extractVideoThumbnailsPositionChanged(value); +} + +void GeneralSettingsObjectWrapper::setFfmpegExecutable(const QString &value) +{ + if (value == prefs.ffmpeg_executable) + return; + + QSettings s; + s.beginGroup(group); + s.setValue("ffmpeg_executable", value); + free((void *)prefs.ffmpeg_executable); + prefs.ffmpeg_executable = copy_qstring(value); + emit ffmpegExecutableChanged(value); +} + LanguageSettingsObjectWrapper::LanguageSettingsObjectWrapper(QObject *parent) : QObject(parent) { diff --git a/core/subsurface-qt/SettingsObjectWrapper.h b/core/subsurface-qt/SettingsObjectWrapper.h index cd30cb1ac..a7957f9f1 100644 --- a/core/subsurface-qt/SettingsObjectWrapper.h +++ b/core/subsurface-qt/SettingsObjectWrapper.h @@ -435,14 +435,17 @@ private: class GeneralSettingsObjectWrapper : public QObject { Q_OBJECT - Q_PROPERTY(QString default_filename READ defaultFilename WRITE setDefaultFilename NOTIFY defaultFilenameChanged) - Q_PROPERTY(QString default_cylinder READ defaultCylinder WRITE setDefaultCylinder NOTIFY defaultCylinderChanged) - Q_PROPERTY(short default_file_behavior READ defaultFileBehavior WRITE setDefaultFileBehavior NOTIFY defaultFileBehaviorChanged) - Q_PROPERTY(bool use_default_file READ useDefaultFile WRITE setUseDefaultFile NOTIFY useDefaultFileChanged) - Q_PROPERTY(int defaultsetpoint READ defaultSetPoint WRITE setDefaultSetPoint NOTIFY defaultSetPointChanged) - Q_PROPERTY(int o2consumption READ o2Consumption WRITE setO2Consumption NOTIFY o2ConsumptionChanged) - Q_PROPERTY(int pscr_ratio READ pscrRatio WRITE setPscrRatio NOTIFY pscrRatioChanged) - Q_PROPERTY(bool auto_recalculate_thumbnails READ autoRecalculateThumbnails WRITE setAutoRecalculateThumbnails NOTIFY autoRecalculateThumbnailsChanged) + Q_PROPERTY(QString default_filename READ defaultFilename WRITE setDefaultFilename NOTIFY defaultFilenameChanged) + Q_PROPERTY(QString default_cylinder READ defaultCylinder WRITE setDefaultCylinder NOTIFY defaultCylinderChanged) + Q_PROPERTY(short default_file_behavior READ defaultFileBehavior WRITE setDefaultFileBehavior NOTIFY defaultFileBehaviorChanged) + Q_PROPERTY(bool use_default_file READ useDefaultFile WRITE setUseDefaultFile NOTIFY useDefaultFileChanged) + Q_PROPERTY(int defaultsetpoint READ defaultSetPoint WRITE setDefaultSetPoint NOTIFY defaultSetPointChanged) + Q_PROPERTY(int o2consumption READ o2Consumption WRITE setO2Consumption NOTIFY o2ConsumptionChanged) + Q_PROPERTY(int pscr_ratio READ pscrRatio WRITE setPscrRatio NOTIFY pscrRatioChanged) + Q_PROPERTY(bool auto_recalculate_thumbnails READ autoRecalculateThumbnails WRITE setAutoRecalculateThumbnails NOTIFY autoRecalculateThumbnailsChanged) + Q_PROPERTY(bool extract_video_thumbnails READ extractVideoThumbnails WRITE setExtractVideoThumbnails NOTIFY extractVideoThumbnailsChanged) + Q_PROPERTY(int extract_video_thumbnails_position READ extractVideoThumbnailsPosition WRITE setExtractVideoThumbnailsPosition NOTIFY extractVideoThumbnailsPositionChanged) + Q_PROPERTY(QString ffmpeg_executable READ ffmpegExecutable WRITE setFfmpegExecutable NOTIFY ffmpegExecutableChanged) public: GeneralSettingsObjectWrapper(QObject *parent); @@ -454,6 +457,9 @@ public: int o2Consumption() const; int pscrRatio() const; bool autoRecalculateThumbnails() const; + bool extractVideoThumbnails() const; + int extractVideoThumbnailsPosition() const; + QString ffmpegExecutable() const; public slots: void setDefaultFilename (const QString& value); @@ -464,6 +470,9 @@ public slots: void setO2Consumption (int value); void setPscrRatio (int value); void setAutoRecalculateThumbnails (bool value); + void setExtractVideoThumbnails (bool value); + void setExtractVideoThumbnailsPosition (int value); + void setFfmpegExecutable (const QString &value); signals: void defaultFilenameChanged(const QString& value); @@ -474,6 +483,9 @@ signals: void o2ConsumptionChanged(int value); void pscrRatioChanged(int value); void autoRecalculateThumbnailsChanged(int value); + void extractVideoThumbnailsChanged(bool value); + void extractVideoThumbnailsPositionChanged(int value); + void ffmpegExecutableChanged(const QString &value); private: const QString group = QStringLiteral("GeneralSettings"); }; diff --git a/core/subsurfacestartup.c b/core/subsurfacestartup.c index d5c44d267..2bb905217 100644 --- a/core/subsurfacestartup.c +++ b/core/subsurfacestartup.c @@ -100,6 +100,8 @@ struct preferences default_prefs = { .cloud_timeout = 5, #endif .auto_recalculate_thumbnails = true, + .extract_video_thumbnails = true, + .extract_video_thumbnails_position = 20, // The first fifth seems like a reasonable place }; int run_survey; @@ -287,6 +289,7 @@ void setup_system_prefs(void) subsurface_OS_pref_setup(); default_prefs.divelist_font = strdup(system_divelist_default_font); default_prefs.font_size = system_divelist_default_font_size; + default_prefs.ffmpeg_executable = strdup("ffmpeg"); #if !defined(SUBSURFACE_MOBILE) default_prefs.default_filename = copy_string(system_default_filename()); @@ -331,6 +334,7 @@ void copy_prefs(struct preferences *src, struct preferences *dest) dest->facebook.access_token = copy_string(src->facebook.access_token); dest->facebook.user_id = copy_string(src->facebook.user_id); dest->facebook.album_id = copy_string(src->facebook.album_id); + dest->ffmpeg_executable = copy_string(src->ffmpeg_executable); } /* diff --git a/core/videoframeextractor.cpp b/core/videoframeextractor.cpp new file mode 100644 index 000000000..c4e16a81b --- /dev/null +++ b/core/videoframeextractor.cpp @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: GPL-2.0 +#include "videoframeextractor.h" +#include "imagedownloader.h" +#include "core/pref.h" +#include "core/dive.h" // for report_error()! + +#include <QtConcurrent> +#include <QProcess> + +// Note: this is a global instead of a function-local variable on purpose. +// We don't want this to be generated in a different thread context if +// VideoFrameExtractor::instance() is called from a worker thread. +static VideoFrameExtractor frameExtractor; +VideoFrameExtractor *VideoFrameExtractor::instance() +{ + return &frameExtractor; +} + +VideoFrameExtractor::VideoFrameExtractor() +{ + // Currently, we only process one video at a time. + // Eventually, we might want to increase this value. + pool.setMaxThreadCount(1); +} + +void VideoFrameExtractor::extract(QString originalFilename, QString filename, duration_t duration) +{ + QMutexLocker l(&lock); + if (!workingOn.contains(originalFilename)) { + // We are not currently extracting this video - add it to the list. + workingOn.insert(originalFilename, QtConcurrent::run(&pool, [this, originalFilename, filename, duration]() + { processItem(originalFilename, filename, duration); })); + } +} + +void VideoFrameExtractor::fail(const QString &originalFilename, duration_t duration, bool isInvalid) +{ + if (isInvalid) + emit invalid(originalFilename, duration); + else + emit failed(originalFilename, duration); + QMutexLocker l(&lock); + workingOn.remove(originalFilename); +} + +void VideoFrameExtractor::clearWorkQueue() +{ + QMutexLocker l(&lock); + for (auto it = workingOn.begin(); it != workingOn.end(); ++it) + it->cancel(); + workingOn.clear(); +} + +// Trivial helper: bring value into given range +template <typename T> +T clamp(T v, T lo, T hi) +{ + return v < lo ? lo : v > hi ? hi : v; +} + +void VideoFrameExtractor::processItem(QString originalFilename, QString filename, duration_t duration) +{ + // If video frame extraction is turned off (e.g. because we failed to start ffmpeg), + // abort immediately. + if (!prefs.extract_video_thumbnails) { + QMutexLocker l(&lock); + workingOn.remove(originalFilename); + return; + } + + // Determine the time where we want to extract the image. + // If the duration is < 10 sec, just snap the first frame + duration_t position = { 0 }; + if (duration.seconds > 10) { + // We round to second-precision. To be sure that we don't attempt reading past the + // video's end, round down by one second. + --duration.seconds; + position.seconds = clamp(duration.seconds * prefs.extract_video_thumbnails_position / 100, + 0, duration.seconds); + } + QString posString = QString("%1:%2:%3").arg(position.seconds / 3600, 2, 10, QChar('0')) + .arg((position.seconds % 3600) / 60, 2, 10, QChar('0')) + .arg(position.seconds % 60, 2, 10, QChar('0')); + + QProcess ffmpeg; + ffmpeg.start(prefs.ffmpeg_executable, QStringList { + "-ss", posString, "-i", filename, "-vframes", "1", "-q:v", "2", "-f", "image2", "-" + }); + if (!ffmpeg.waitForStarted()) { + // Since we couldn't sart ffmpeg, turn off thumbnailing + // TODO: call the proper preferences-functions + prefs.extract_video_thumbnails = false; + report_error(qPrintable(tr("ffmpeg failed to start - video thumbnail creation suspended"))); + qDebug() << "Failed to start ffmpeg"; + return fail(originalFilename, duration, false); + } + if (!ffmpeg.waitForFinished()) { + qDebug() << "Failed waiting for ffmpeg"; + report_error(qPrintable(tr("failed waiting for ffmpeg - video thumbnail creation suspended"))); + return fail(originalFilename, duration, false); + } + + QByteArray data = ffmpeg.readAll(); + QImage img; + img.loadFromData(data); + if (img.isNull()) { + qInfo() << "Failed reading ffmpeg output"; + // For debugging: + //qInfo() << "stdout: " << QString::fromUtf8(data); + ffmpeg.setReadChannel(QProcess::StandardError); + // For debugging: + //QByteArray stderr_output = ffmpeg.readAll(); + //qInfo() << "stderr: " << QString::fromUtf8(stderr_output); + return fail(originalFilename, duration, true); + } + + emit extracted(originalFilename, img, duration, position); + QMutexLocker l(&lock); + workingOn.remove(originalFilename); +} diff --git a/core/videoframeextractor.h b/core/videoframeextractor.h new file mode 100644 index 000000000..26650af8d --- /dev/null +++ b/core/videoframeextractor.h @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-2.0 +#ifndef VIDEOFRAMEEXTRACTOR_H +#define VIDEOFRAMEEXTRACTOR_H + +#include "core/units.h" + +#include <QMutex> +#include <QFuture> +#include <QThreadPool> +#include <QQueue> +#include <QString> +#include <QPair> + +class VideoFrameExtractor : public QObject { + Q_OBJECT +public: + VideoFrameExtractor(); + static VideoFrameExtractor *instance(); +signals: + void extracted(QString filename, QImage, duration_t duration, duration_t offset); + // There are two failure modes: + // failed() -> we failed to start ffmpeg. Write a thumbnail signalling "maybe try again". + // invalid() -> we started ffmpeg, but that couldn't extract an image. Signal "this file is broken". + void failed(QString filename, duration_t duration); + void invalid(QString filename, duration_t duration); +public slots: + void extract(QString originalFilename, QString filename, duration_t duration); + void clearWorkQueue(); +private: + void processItem(QString originalFilename, QString filename, duration_t duration); + void fail(const QString &originalFilename, duration_t duration, bool isInvalid); + mutable QMutex lock; + QThreadPool pool; + QMap<QString, QFuture<void>> workingOn; +}; + +#endif diff --git a/desktop-widgets/preferences/preferences_defaults.cpp b/desktop-widgets/preferences/preferences_defaults.cpp index a7b5e4c1d..4d8aef610 100644 --- a/desktop-widgets/preferences/preferences_defaults.cpp +++ b/desktop-widgets/preferences/preferences_defaults.cpp @@ -43,6 +43,22 @@ void PreferencesDefaults::on_localDefaultFile_toggled(bool toggle) ui->chooseFile->setEnabled(toggle); } +void PreferencesDefaults::on_ffmpegFile_clicked() +{ + QFileInfo fi(system_default_filename()); + QString ffmpegFileName = QFileDialog::getOpenFileName(this, tr("Select ffmpeg executable")); + + if (!ffmpegFileName.isEmpty()) + ui->ffmpegExecutable->setText(ffmpegFileName); +} + +void PreferencesDefaults::on_extractVideoThumbnails_toggled(bool toggled) +{ + ui->videoThumbnailPosition->setEnabled(toggled); + ui->ffmpegExecutable->setEnabled(toggled); + ui->ffmpegFile->setEnabled(toggled); +} + void PreferencesDefaults::refreshSettings() { ui->font->setCurrentFont(QString(prefs.divelist_font)); @@ -73,6 +89,14 @@ void PreferencesDefaults::refreshSettings() ui->defaultfilename->setEnabled(prefs.default_file_behavior == LOCAL_DEFAULT_FILE); ui->btnUseDefaultFile->setEnabled(prefs.default_file_behavior == LOCAL_DEFAULT_FILE); ui->chooseFile->setEnabled(prefs.default_file_behavior == LOCAL_DEFAULT_FILE); + + ui->videoThumbnailPosition->setEnabled(prefs.extract_video_thumbnails); + ui->ffmpegExecutable->setEnabled(prefs.extract_video_thumbnails); + ui->ffmpegFile->setEnabled(prefs.extract_video_thumbnails); + + ui->extractVideoThumbnails->setChecked(prefs.extract_video_thumbnails); + ui->videoThumbnailPosition->setValue(prefs.extract_video_thumbnails_position); + ui->ffmpegExecutable->setText(prefs.ffmpeg_executable); } void PreferencesDefaults::syncSettings() @@ -87,6 +111,9 @@ void PreferencesDefaults::syncSettings() general->setDefaultFileBehavior(LOCAL_DEFAULT_FILE); else if (ui->cloudDefaultFile->isChecked()) general->setDefaultFileBehavior(CLOUD_DEFAULT_FILE); + general->setExtractVideoThumbnails(ui->extractVideoThumbnails->isChecked()); + general->setExtractVideoThumbnailsPosition(ui->videoThumbnailPosition->value()); + general->setFfmpegExecutable(ui->ffmpegExecutable->text()); auto display = qPrefDisplay::instance(); display->set_divelist_font(ui->font->currentFont().toString()); diff --git a/desktop-widgets/preferences/preferences_defaults.h b/desktop-widgets/preferences/preferences_defaults.h index 1674dd2ad..d7bb7db7f 100644 --- a/desktop-widgets/preferences/preferences_defaults.h +++ b/desktop-widgets/preferences/preferences_defaults.h @@ -20,6 +20,8 @@ public slots: void on_chooseFile_clicked(); void on_btnUseDefaultFile_toggled(bool toggled); void on_localDefaultFile_toggled(bool toggled); + void on_ffmpegFile_clicked(); + void on_extractVideoThumbnails_toggled(bool toggled); private: Ui::PreferencesDefaults *ui; diff --git a/desktop-widgets/preferences/preferences_defaults.ui b/desktop-widgets/preferences/preferences_defaults.ui index 1a54d7b0a..731eed6ab 100644 --- a/desktop-widgets/preferences/preferences_defaults.ui +++ b/desktop-widgets/preferences/preferences_defaults.ui @@ -73,7 +73,7 @@ <item> <widget class="QRadioButton" name="noDefaultFile"> <property name="text"> - <string>No default file</string> + <string>&No default file</string> </property> </widget> </item> @@ -210,6 +210,79 @@ </widget> </item> <item> + <widget class="QGroupBox" name="groupBox_10"> + <property name="title"> + <string>Video thumbnails</string> + </property> + <layout class="QFormLayout" name="formLayout"> + <property name="horizontalSpacing"> + <number>5</number> + </property> + <property name="verticalSpacing"> + <number>5</number> + </property> + <property name="margin"> + <number>5</number> + </property> + <item row="1" column="0"> + <widget class="QLabel" name="ffmpegExectuableLabel"> + <property name="text"> + <string>ffmpeg executable</string> + </property> + </widget> + </item> + <item row="1" column="1"> + <layout class="QHBoxLayout" name="horizontalLayout_3b"> + <item> + <widget class="QLineEdit" name="ffmpegExecutable"/> + </item> + <item> + <widget class="QToolButton" name="ffmpegFile"> + <property name="text"> + <string>...</string> + </property> + </widget> + </item> + </layout> + </item> + <item row="3" column="0"> + <widget class="QLabel" name="videoThumbnailPositionLabel"> + <property name="text"> + <string>Extract at position</string> + </property> + </widget> + </item> + <item row="3" column="1"> + <widget class="QSlider" name="videoThumbnailPosition"> + <property name="maximum"> + <number>100</number> + </property> + <property name="value"> + <number>20</number> + </property> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + </widget> + </item> + <item row="0" column="0"> + <widget class="QLabel" name="extractVideoThumbnailsLabel"> + <property name="text"> + <string>Extract video thumbnails</string> + </property> + </widget> + </item> + <item row="0" column="1"> + <widget class="QCheckBox" name="extractVideoThumbnails"> + <property name="text"> + <string/> + </property> + </widget> + </item> + </layout> + </widget> + </item> + <item> <widget class="QGroupBox" name="groupBox_9"> <property name="title"> <string>Clear all settings</string> diff --git a/icons/video_overlay.svg b/icons/video_overlay.svg new file mode 100644 index 000000000..87ad6a6e3 --- /dev/null +++ b/icons/video_overlay.svg @@ -0,0 +1,263 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + height="66" + width="22" + id="svg25756" + version="1.1" + viewBox="0 0 22 66" + sodipodi:docname="video_overlay.svg" + inkscape:version="0.92.3 (2405546, 2018-03-11)"> + <sodipodi:namedview + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1" + objecttolerance="10" + gridtolerance="10" + guidetolerance="10" + inkscape:pageopacity="0" + inkscape:pageshadow="2" + inkscape:window-width="1920" + inkscape:window-height="1015" + id="namedview870" + showgrid="false" + inkscape:zoom="12.242424" + inkscape:cx="11" + inkscape:cy="33" + inkscape:window-x="0" + inkscape:window-y="0" + inkscape:window-maximized="1" + inkscape:current-layer="svg25756" /> + <metadata + id="metadata25760"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title></dc:title> + </cc:Work> + </rdf:RDF> + </metadata> + <defs + id="defs3051"> + <style + id="current-color-scheme" + type="text/css"> + .ColorScheme-Text { + color:#4d4d4d; + } + </style> + </defs> + <path + id="path1012" + d="M 1.6913554e-16,4.7920842e-9 H 2.7632416 V 66 H 0 Z" + style="color:#4d4d4d;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.99999994" /> + <path + id="path904" + d="m 9,31 v 4 l 4,-2 z" + style="color:#4d4d4d;fill:#000000;fill-opacity:1;stroke:#ffffff;stroke-width:0.2;stroke-miterlimit:4;stroke-dasharray:none;stroke-linejoin:bevel;stroke-linecap:round" /> + <path + id="path888" + d="M 0.69081079,39.883391 H 2.0724316 v 1.381621 H 0.69081079 Z" + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" /> + <path + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" + d="M 0.69081079,36.883391 H 2.0724316 v 1.381621 H 0.69081079 Z" + id="path916" /> + <path + id="path920" + d="M 0.69081079,33.883391 H 2.0724316 v 1.381621 H 0.69081079 Z" + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" /> + <path + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" + d="M 0.69081079,30.883391 H 2.0724316 v 1.381621 H 0.69081079 Z" + id="path924" /> + <path + id="path928" + d="M 0.69081079,27.883391 H 2.0724316 v 1.381621 H 0.69081079 Z" + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" /> + <path + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" + d="M 0.69081079,24.883391 H 2.0724316 v 1.381621 H 0.69081079 Z" + id="path932" /> + <path + id="path936" + d="M 0.69081079,21.883391 H 2.0724316 v 1.381621 H 0.69081079 Z" + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" /> + <path + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" + d="M 0.69081079,18.883391 H 2.0724316 v 1.381621 H 0.69081079 Z" + id="path940" /> + <path + id="path944" + d="M 0.69081079,15.883391 H 2.0724316 v 1.381621 H 0.69081079 Z" + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" /> + <path + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" + d="M 0.69081079,12.883391 H 2.0724316 v 1.381621 H 0.69081079 Z" + id="path948" /> + <path + id="path952" + d="M 0.69081079,9.8833914 H 2.0724306 V 11.265012 H 0.69081079 Z" + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" /> + <path + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" + d="M 0.69081079,6.8833914 H 2.0724316 v 1.381621 H 0.69081079 Z" + id="path956" /> + <path + id="path960" + d="M 0.69081079,3.8833914 H 2.0724316 v 1.381621 H 0.69081079 Z" + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" /> + <path + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" + d="M 0.69081079,0.88339134 H 2.0724306 V 2.2650124 H 0.69081079 Z" + id="path964" /> + <path + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" + d="M 0.69081079,42.883391 H 2.0724316 v 1.381621 H 0.69081079 Z" + id="path968" /> + <path + id="path976" + d="M 0.69081079,45.883391 H 2.0724316 v 1.381621 H 0.69081079 Z" + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" /> + <path + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" + d="M 0.69081079,48.883391 H 2.0724316 v 1.381621 H 0.69081079 Z" + id="path980" /> + <path + id="path984" + d="M 0.69081079,51.883391 H 2.0724316 v 1.381621 H 0.69081079 Z" + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" /> + <path + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" + d="M 0.69081079,54.883391 H 2.0724316 v 1.381621 H 0.69081079 Z" + id="path988" /> + <path + id="path992" + d="M 0.69081079,57.883391 H 2.0724316 v 1.381621 H 0.69081079 Z" + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" /> + <path + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" + d="M 0.69081079,60.883391 H 2.0724316 v 1.381621 H 0.69081079 Z" + id="path996" /> + <path + id="path1000" + d="M 0.69081079,60.883391 H 2.0724316 v 1.381621 H 0.69081079 Z" + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" /> + <path + id="path1004" + d="M 0.69081079,63.883391 H 2.0724316 v 1.381621 H 0.69081079 Z" + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" /> + <path + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" + d="M 0.69081079,63.883391 H 2.0724316 v 1.381621 H 0.69081079 Z" + id="path1008" /> + <path + style="color:#4d4d4d;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.99999994" + d="m 19.237,4.7920842e-9 h 2.763242 V 66 H 19.237 Z" + id="path1018" /> + <path + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" + d="m 19.927811,39.883391 h 1.381621 v 1.381621 h -1.381621 z" + id="path1020" /> + <path + id="path1022" + d="m 19.927811,36.883391 h 1.381621 v 1.381621 h -1.381621 z" + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" /> + <path + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" + d="m 19.927811,33.883391 h 1.381621 v 1.381621 h -1.381621 z" + id="path1024" /> + <path + id="path1026" + d="m 19.927811,30.883391 h 1.381621 v 1.381621 h -1.381621 z" + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" /> + <path + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" + d="m 19.927811,27.883391 h 1.381621 v 1.381621 h -1.381621 z" + id="path1028" /> + <path + id="path1030" + d="m 19.927811,24.883391 h 1.381621 v 1.381621 h -1.381621 z" + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" /> + <path + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" + d="m 19.927811,21.883391 h 1.381621 v 1.381621 h -1.381621 z" + id="path1032" /> + <path + id="path1034" + d="m 19.927811,18.883391 h 1.381621 v 1.381621 h -1.381621 z" + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" /> + <path + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" + d="m 19.927811,15.883391 h 1.381621 v 1.381621 h -1.381621 z" + id="path1036" /> + <path + id="path1038" + d="m 19.927811,12.883391 h 1.381621 v 1.381621 h -1.381621 z" + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" /> + <path + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" + d="m 19.927811,9.8833914 h 1.38162 v 1.3816206 h -1.38162 z" + id="path1040" /> + <path + id="path1042" + d="m 19.927811,6.8833914 h 1.381621 v 1.381621 h -1.381621 z" + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" /> + <path + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" + d="m 19.927811,3.8833914 h 1.381621 v 1.381621 h -1.381621 z" + id="path1044" /> + <path + id="path1046" + d="m 19.927811,0.88339134 h 1.38162 V 2.2650124 h -1.38162 z" + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" /> + <path + id="path1048" + d="m 19.927811,42.883391 h 1.381621 v 1.381621 h -1.381621 z" + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" /> + <path + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" + d="m 19.927811,45.883391 h 1.381621 v 1.381621 h -1.381621 z" + id="path1050" /> + <path + id="path1052" + d="m 19.927811,48.883391 h 1.381621 v 1.381621 h -1.381621 z" + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" /> + <path + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" + d="m 19.927811,51.883391 h 1.381621 v 1.381621 h -1.381621 z" + id="path1054" /> + <path + id="path1056" + d="m 19.927811,54.883391 h 1.381621 v 1.381621 h -1.381621 z" + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" /> + <path + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" + d="m 19.927811,57.883391 h 1.381621 v 1.381621 h -1.381621 z" + id="path1058" /> + <path + id="path1060" + d="m 19.927811,60.883391 h 1.381621 v 1.381621 h -1.381621 z" + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" /> + <path + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" + d="m 19.927811,60.883391 h 1.381621 v 1.381621 h -1.381621 z" + id="path1062" /> + <path + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" + d="m 19.927811,63.883391 h 1.381621 v 1.381621 h -1.381621 z" + id="path1064" /> + <path + id="path1066" + d="m 19.927811,63.883391 h 1.381621 v 1.381621 h -1.381621 z" + style="color:#4d4d4d;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.99999994" /> +</svg> diff --git a/subsurface.qrc b/subsurface.qrc index 1281702ae..2caf317c6 100644 --- a/subsurface.qrc +++ b/subsurface.qrc @@ -93,6 +93,7 @@ <file alias="preferences-other-icon">icons/defaults.png</file> <file alias="camera-icon">icons/camera.svg</file> <file alias="video-icon">icons/video.svg</file> + <file alias="video-overlay">icons/video_overlay.svg</file> <file alias="unknown-icon">icons/unknown.svg</file> </qresource> </RCC> |