diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 92187b4043..c30077897d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -93,6 +93,7 @@ add_executable(shotcut WIN32 MACOSX_BUNDLE models/alignclipsmodel.cpp models/alignclipsmodel.h models/attachedfiltersmodel.cpp models/attachedfiltersmodel.h models/audiolevelstask.cpp models/audiolevelstask.h + models/keyframestask.cpp models/keyframestask.h models/extensionmodel.cpp models/extensionmodel.h models/keyframesmodel.cpp models/keyframesmodel.h models/markersmodel.cpp models/markersmodel.h diff --git a/src/docks/timelinedock.cpp b/src/docks/timelinedock.cpp index c39b08147d..c704bdbd64 100644 --- a/src/docks/timelinedock.cpp +++ b/src/docks/timelinedock.cpp @@ -1685,7 +1685,17 @@ void TimelineDock::setPosition(int position) emit seeked(position); } else { m_position = m_model.tractor()->get_length(); + m_requestedPosition = m_position; emit positionChanged(m_position); + emit requestedPositionChanged(m_requestedPosition); + } +} + +void TimelineDock::setRequestedPosition(int position) +{ + if (MLT.isMultitrack() && m_requestedPosition != position && m_model.tractor()) { + m_requestedPosition = qMin(position, m_model.tractor()->get_length()); + emit requestedPositionChanged(m_requestedPosition); } } @@ -3929,6 +3939,7 @@ void TimelineDock::onMultitrackClosed() { stopRecording(); m_position = -1; + m_requestedPosition = -1; m_ignoreNextPositionChange = false; m_trimDelta = 0; m_transitionDelta = 0; diff --git a/src/docks/timelinedock.h b/src/docks/timelinedock.h index 167b2626c0..1994067aa5 100644 --- a/src/docks/timelinedock.h +++ b/src/docks/timelinedock.h @@ -42,6 +42,7 @@ class TimelineDock : public QDockWidget { Q_OBJECT Q_PROPERTY(int position READ position WRITE setPosition NOTIFY positionChanged) + Q_PROPERTY(int requestedPosition READ requestedPosition NOTIFY requestedPositionChanged) Q_PROPERTY(int currentTrack READ currentTrack WRITE setCurrentTrack NOTIFY currentTrackChanged) Q_PROPERTY( QVariantList selection READ selectionForJS WRITE setSelectionFromJS NOTIFY selectionChanged) @@ -60,7 +61,9 @@ class TimelineDock : public QDockWidget SubtitlesModel *subtitlesModel() { return &m_subtitlesModel; } SubtitlesSelectionModel *subtitlesSelectionModel() { return &m_subtitlesSelectionModel; } int position() const { return m_position; } + int requestedPosition() const { return m_requestedPosition; } void setPosition(int position); + void setRequestedPosition(int position); Mlt::Producer producerForClip(int trackIndex, int clipIndex); int clipIndexAtPlayhead(int trackIndex = -1); int clipIndexAtPosition(int trackIndex, int position); @@ -102,6 +105,7 @@ class TimelineDock : public QDockWidget void selectionChanged(); void seeked(int position); void positionChanged(int position); + void requestedPositionChanged(int position); void loopChanged(); void clipOpened(Mlt::Producer *producer); void dragging(const QPointF &pos, int duration); @@ -242,6 +246,7 @@ public slots: SubtitlesModel m_subtitlesModel; SubtitlesSelectionModel m_subtitlesSelectionModel; int m_position{-1}; + int m_requestedPosition{-1}; std::unique_ptr m_updateCommand; bool m_ignoreNextPositionChange{false}; struct Selection diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 6e3f389591..52e1900229 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -52,6 +52,7 @@ #include "jobs/screencapturejob.h" #include "models/audiolevelstask.h" #include "models/keyframesmodel.h" +#include "models/keyframestask.h" #include "models/motiontrackermodel.h" #include "openotherdialog.h" #include "player.h" @@ -526,6 +527,10 @@ void MainWindow::setupAndConnectDocks() SLOT(onTimelineDockTriggered(bool))); connect(ui->actionTimeline, SIGNAL(triggered()), SLOT(onTimelineDockTriggered())); connect(m_player, SIGNAL(seeked(int)), m_timelineDock, SLOT(onSeeked(int))); + connect(m_player, + &Player::positionRequested, + m_timelineDock, + &TimelineDock::setRequestedPosition); connect(m_timelineDock, SIGNAL(seeked(int)), SLOT(seekTimeline(int))); connect(m_timelineDock, SIGNAL(clipClicked()), SLOT(onTimelineClipSelected())); connect(m_timelineDock, @@ -3318,6 +3323,7 @@ void MainWindow::closeEvent(QCloseEvent *event) } QThreadPool::globalInstance()->clear(); AudioLevelsTask::closeAll(); + KeyframesTask::closeAll(); event->accept(); emit aboutToShutDown(); if (m_exitCode == EXIT_SUCCESS) { @@ -3418,6 +3424,7 @@ void MainWindow::onProducerOpened(bool withReopen) m_player->enableTab(Player::SourceTabIndex); m_player->switchToTab(MLT.isClosedClip() ? Player::ProjectTabIndex : Player::SourceTabIndex); Util::getHash(*MLT.producer()); + KeyframesTask::start(*MLT.producer()); } ui->actionSave->setEnabled(true); QMutexLocker locker(&m_autosaveMutex); diff --git a/src/mltcontroller.cpp b/src/mltcontroller.cpp index 3490dc38b1..5ea398bc12 100644 --- a/src/mltcontroller.cpp +++ b/src/mltcontroller.cpp @@ -193,6 +193,7 @@ bool Controller::openXML(const QString &filename) void Controller::close() { + m_lastSeekedPosition = -1; if (m_profile.is_explicit()) { pause(); } else if (m_consumer && !m_consumer->is_stopped()) { @@ -214,6 +215,7 @@ void Controller::closeConsumer() void Controller::play(double speed) { + m_lastSeekedPosition = -1; if (m_jackFilter) { if (speed == 1.0) m_jackFilter->fire_event("jack-start"); @@ -443,11 +445,16 @@ void Controller::seek(int position) if (m_consumer->is_stopped()) { m_consumer->start(); } else { - m_consumer->purge(); - Controller::refreshConsumer(Settings.playerScrubAudio()); + bool scrubAudio = false; + if (position != m_lastSeekedPosition) { + m_consumer->purge(); + scrubAudio = Settings.playerScrubAudio(); + } + Controller::refreshConsumer(scrubAudio); } } } + m_lastSeekedPosition = position; if (m_jackFilter) { if (Settings.playerPauseAfterSeek()) stopJack(); diff --git a/src/mltcontroller.h b/src/mltcontroller.h index de46f13994..239cc94b47 100644 --- a/src/mltcontroller.h +++ b/src/mltcontroller.h @@ -212,6 +212,7 @@ class Controller QString m_projectFolder; QMutex m_saveXmlMutex; bool m_blockRefresh; + int m_lastSeekedPosition{-1}; static void on_jack_started(mlt_properties owner, void *object, mlt_event_data data); void onJackStarted(int position); diff --git a/src/models/keyframestask.cpp b/src/models/keyframestask.cpp new file mode 100644 index 0000000000..c2c7faabec --- /dev/null +++ b/src/models/keyframestask.cpp @@ -0,0 +1,378 @@ +/* + * Copyright (c) 2025 Meltytech, LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "keyframestask.h" + +#include "Logger.h" +#include "database.h" +#include "mltcontroller.h" +#include "shotcut_mlt_properties.h" +#include "util.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Pixel value used to pad unused columns in the DB cache image. +static constexpr QRgb kPaddingSentinel = 0xFFFFFFFFu; + +static QList tasksList; +static QMutex tasksListMutex; + +static void deleteKeyframesMap(void *data) +{ + delete static_cast> *>(data); +} + +static bool isVideoProducer(Mlt::Producer *p) +{ + // Exclude audio-only producers + if (p->get("video_index") && p->get_int("video_index") < 0) + return false; + return true; +} + +KeyframesTask::KeyframesTask(Mlt::Producer &producer) + : QRunnable() + , m_isCanceled(false) + , m_isForce(false) +{ + m_producers << new Mlt::Producer(producer); +} + +KeyframesTask::~KeyframesTask() +{ + qDeleteAll(m_producers); +} + +void KeyframesTask::start(Mlt::Producer &producer, bool force) +{ + if (!producer.is_valid()) + return; + + QString serviceName = QString::fromLatin1(producer.get("mlt_service")); + // Accept avformat*, avformat-novalidate, and chain (chain wraps avformat for Source tab) + bool isAvformat = serviceName.startsWith("avformat"); + bool isChain = (serviceName == "chain"); + if (!isAvformat && !isChain) + return; + + // Skip audio-only + if (!isVideoProducer(&producer)) + return; + + // Require a resource path for ffprobe + const char *resource = producer.get("resource"); + if (!resource || !resource[0]) + return; + + // Skip if already have valid keyframes and not forcing + if (!force && producer.get_data(kKeyframesProperty)) + return; + + KeyframesTask *task = new KeyframesTask(producer); + tasksListMutex.lock(); + foreach (KeyframesTask *t, tasksList) { + if (*t == *task) { + // Merge: add this producer to the existing task's notify list + delete task; + task = nullptr; + t->m_producers << new Mlt::Producer(producer); + break; + } + } + if (task) { + task->m_isForce = force; + tasksList << task; + QThreadPool::globalInstance()->start(task); + } + tasksListMutex.unlock(); +} + +void KeyframesTask::closeAll() +{ + tasksListMutex.lock(); + while (!tasksList.isEmpty()) { + KeyframesTask *task = tasksList.first(); + task->m_isCanceled = true; + tasksList.removeFirst(); + } + tasksListMutex.unlock(); +} + +bool KeyframesTask::operator==(const KeyframesTask &b) const +{ + if (!m_producers.isEmpty() && !b.m_producers.isEmpty()) { + const char *aRes = m_producers.first()->get("resource"); + const char *bRes = b.m_producers.first()->get("resource"); + return aRes && bRes && !qstrcmp(aRes, bRes); + } + return false; +} + +void KeyframesTask::setProperty(const QMap> &keyframes) +{ + foreach (Mlt::Producer *p, m_producers) { + auto *copy = new QMap>(keyframes); + p->lock(); + p->set(kKeyframesProperty, copy, 0, deleteKeyframesMap); + p->unlock(); + } +} + +QString KeyframesTask::cacheKey() const +{ + Mlt::Producer *producer = m_producers.first(); + QString key; + if (producer->get(kShotcutHashProperty)) { + key = QStringLiteral("%1 keyframes").arg(producer->get(kShotcutHashProperty)); + } else { + key = QStringLiteral("%1 keyframes").arg(producer->get("resource")); + QCryptographicHash hash(QCryptographicHash::Sha1); + hash.addData(key.toUtf8()); + key = hash.result().toHex(); + } + return key; +} + +void KeyframesTask::run() +{ + // Remove from the global task list unconditionally so that no exit path + // (early return, cancellation, or normal completion) can leave a dangling + // pointer that the next start() call would dereference. + tasksListMutex.lock(); + tasksList.removeOne(this); + tasksListMutex.unlock(); + + if (m_isCanceled || m_producers.isEmpty()) + return; + + Mlt::Producer *producer = m_producers.first(); + QString resource = QString::fromUtf8(producer->get("resource")); + if (resource.isEmpty()) + return; + + const QString key = cacheKey(); + QImage cachedImage = DB.getThumbnail(key); + + if (!cachedImage.isNull() && !m_isForce) { + // Restore from DB cache. + // Image format: col 0 = stream index (as QRgb uint), cols 1..W-1 = keyframe frame numbers. + // Unused columns are filled with kPaddingSentinel. kIntraOnlySentinel in a slot = intra-only. + QMap> kfMap; + for (int y = 0; y < cachedImage.height(); ++y) { + int streamIdx = static_cast(static_cast(cachedImage.pixel(0, y))); + QVector kf; + for (int x = 1; x < cachedImage.width(); ++x) { + QRgb val = static_cast(cachedImage.pixel(x, y)); + if (val == kPaddingSentinel) + break; + kf.append(static_cast(val)); + } + if (!kf.isEmpty()) + kfMap[streamIdx] = kf; + } + if (!kfMap.isEmpty()) { + setProperty(kfMap); + int total = 0; + for (const auto &kf : kfMap) + total += kf.size(); + LOG_DEBUG() << "KeyframesTask: loaded" << total << "keyframes from cache for" + << resource; + } + return; + } + + // Locate ffprobe alongside the application + QString ffprobePath = QFileInfo(qApp->applicationDirPath(), "ffprobe").absoluteFilePath(); + QStringList args; + args << "-v" + << "quiet" + << "-select_streams" + << "v" + << "-show_packets" + << "-show_entries" + << "packet=stream_index,pts_time,flags" + << "-of" + << "csv" << resource; + + QProcess proc; + proc.start(ffprobePath, args, QIODevice::ReadOnly); + if (!proc.waitForStarted(5000)) { + LOG_WARNING() << "KeyframesTask: ffprobe failed to start for" << resource; + return; + } + + double fps = 0.0; + if (producer->get_double("meta.media.frame_rate_den") != 0.0) { + fps = producer->get_double("meta.media.frame_rate_num") + / producer->get_double("meta.media.frame_rate_den"); + } + if (fps <= 0.0) + fps = MLT.profile().fps(); + + // Per-stream state: keyed by video stream index from ffprobe output. + struct StreamState + { + QVector keyframes; + double firstPtsTime{-1.0}; + int packetCount{0}; + bool mightBeIntraOnly{true}; + }; + QMap streams; + const int INTRA_ONLY_CHECK = 60; + + QElapsedTimer updateTimer; + updateTimer.start(); + + while (!m_isCanceled) { + if (!proc.waitForReadyRead(1000)) { + if (proc.state() == QProcess::NotRunning) + break; + continue; + } + while (proc.canReadLine()) { + if (m_isCanceled) { + proc.kill(); + proc.waitForFinished(1000); + return; + } + const QByteArray line = proc.readLine().trimmed(); + // Expected CSV line: "packet,,," + const QList parts = line.split(','); + if (parts.size() < 4 || parts[0] != "packet") + continue; + + bool okIdx = false, okPts = false; + const int streamIdx = parts[1].toInt(&okIdx); + const double ptsTime = parts[2].toDouble(&okPts); + if (!okIdx || !okPts) + continue; + + StreamState &s = streams[streamIdx]; + if (s.firstPtsTime < 0.0) + s.firstPtsTime = ptsTime; + + const bool isKeyframe = parts[3].contains('K'); + ++s.packetCount; + + if (s.mightBeIntraOnly && !isKeyframe) + s.mightBeIntraOnly = false; + + if (s.mightBeIntraOnly && s.packetCount >= INTRA_ONLY_CHECK) { + // Stream confirmed intra-only; tag it and stop collecting keyframes for it. + s.keyframes = {kIntraOnlySentinel}; + } else if (isKeyframe + && (s.keyframes.isEmpty() || s.keyframes.last() != kIntraOnlySentinel)) { + const int frameNum = qRound((ptsTime - s.firstPtsTime) * fps); + s.keyframes.append(frameNum); + } + + // Early exit when every observed video stream is confirmed intra-only. + bool allIntraOnly = !streams.isEmpty(); + for (const auto &st : std::as_const(streams)) { + if (!st.mightBeIntraOnly || st.packetCount < INTRA_ONLY_CHECK) { + allIntraOnly = false; + break; + } + } + if (allIntraOnly) { + proc.kill(); + proc.waitForFinished(1000); + QMap> kfMap; + for (auto it = streams.cbegin(); it != streams.cend(); ++it) + kfMap[it.key()] = {kIntraOnlySentinel}; + setProperty(kfMap); + const QList intraKeys = kfMap.keys(); + QImage img(2, intraKeys.size(), QImage::Format_ARGB32); + img.fill(kPaddingSentinel); + for (int y = 0; y < intraKeys.size(); ++y) { + img.setPixel(0, y, static_cast(intraKeys[y])); + img.setPixel(1, y, static_cast(static_cast(kIntraOnlySentinel))); + } + DB.putThumbnail(key, img); + LOG_DEBUG() << "KeyframesTask: intra-only detected for" << resource; + return; + } + } + + // Periodic intermediate result every 3 seconds + if (updateTimer.elapsed() >= 3000) { + QMap> partial; + for (auto it = streams.cbegin(); it != streams.cend(); ++it) { + if (!it->keyframes.isEmpty()) + partial[it.key()] = it->keyframes; + } + if (!partial.isEmpty()) { + setProperty(partial); + updateTimer.restart(); + } + } + } + + proc.waitForFinished(2000); + if (proc.exitStatus() != QProcess::NormalExit) + proc.kill(); + + // Mark any stream that finished with all-keyframe packets but fewer than + // INTRA_ONLY_CHECK (e.g. very short clip) as intra-only. + for (auto it = streams.begin(); it != streams.end(); ++it) { + if (it->mightBeIntraOnly && it->keyframes.isEmpty()) + it->keyframes = {kIntraOnlySentinel}; + } + + QMap> kfMap; + for (auto it = streams.cbegin(); it != streams.cend(); ++it) { + if (!it->keyframes.isEmpty()) + kfMap[it.key()] = it->keyframes; + } + + if (m_isCanceled || kfMap.isEmpty()) + return; + + // Persist to DB. + // Image layout: col 0 = stream index (as QRgb uint), cols 1..W-1 = keyframe frame numbers. + // Unused columns are filled with kPaddingSentinel. + int maxKF = 0; + for (const auto &kf : kfMap) + maxKF = qMax(maxKF, kf.size()); + const QList sortedKeys = kfMap.keys(); + QImage img(1 + maxKF, sortedKeys.size(), QImage::Format_ARGB32); + img.fill(kPaddingSentinel); + for (int y = 0; y < sortedKeys.size(); ++y) { + const int si = sortedKeys[y]; + img.setPixel(0, y, static_cast(si)); + const QVector &kf = kfMap[si]; + for (int x = 0; x < kf.size(); ++x) + img.setPixel(x + 1, y, static_cast(kf[x])); + } + DB.putThumbnail(key, img); + + setProperty(kfMap); + int total = 0; + for (const auto &kf : kfMap) + total += kf.size(); + LOG_DEBUG() << "KeyframesTask: stored" << total << "keyframes for" << resource << "across" + << kfMap.size() << "video stream(s)"; +} diff --git a/src/models/keyframestask.h b/src/models/keyframestask.h new file mode 100644 index 0000000000..99559a48ab --- /dev/null +++ b/src/models/keyframestask.h @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Meltytech, LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEYFRAMESTASK_H +#define KEYFRAMESTASK_H + +#include +#include +#include +#include +#include +#include + +// Stored in a stream's keyframe vector to mean "this stream is intra-only; +// every frame is a keyframe, so two-phase seek can be skipped." +static constexpr int kIntraOnlySentinel = INT_MAX; + +class KeyframesTask : public QRunnable +{ +public: + explicit KeyframesTask(Mlt::Producer &producer); + virtual ~KeyframesTask(); + static void start(Mlt::Producer &producer, bool force = false); + static void closeAll(); + bool operator==(const KeyframesTask &b) const; + +protected: + void run() override; + +private: + void setProperty(const QMap> &keyframes); + QString cacheKey() const; + + QList m_producers; + bool m_isCanceled; + bool m_isForce; +}; + +#endif // KEYFRAMESTASK_H diff --git a/src/models/multitrackmodel.cpp b/src/models/multitrackmodel.cpp index 28f72f28f5..92a80086d9 100644 --- a/src/models/multitrackmodel.cpp +++ b/src/models/multitrackmodel.cpp @@ -22,6 +22,7 @@ #include "controllers/filtercontroller.h" #include "dialogs/longuitask.h" #include "docks/playlistdock.h" +#include "keyframestask.h" #include "mainwindow.h" #include "mltcontroller.h" #include "proxymanager.h" @@ -1003,6 +1004,7 @@ int MultitrackModel::overwriteClip(int trackIndex, Mlt::Producer &clip, int posi if (result >= 0) { QModelIndex index = createIndex(result, 0, trackIndex); AudioLevelsTask::start(clip.parent(), this, index); + KeyframesTask::start(clip.parent()); emit modified(); if (seek) emit seeked(playlist.clip_start(result) + playlist.clip_length(result)); @@ -1099,6 +1101,7 @@ QString MultitrackModel::overwrite( } QModelIndex index = createIndex(targetIndex, 0, trackIndex); AudioLevelsTask::start(clip.parent(), this, index); + KeyframesTask::start(clip.parent()); if (notify) { emit overWritten(trackIndex, targetIndex); emit modified(); @@ -1203,6 +1206,7 @@ int MultitrackModel::insertClip( QModelIndex index = createIndex(result, 0, trackIndex); AudioLevelsTask::start(clip.parent(), this, index); + KeyframesTask::start(clip.parent()); if (notify) { emit inserted(trackIndex, result); emit modified(); @@ -1234,6 +1238,7 @@ int MultitrackModel::appendClip(int trackIndex, Mlt::Producer &clip, bool seek, endInsertRows(); QModelIndex index = createIndex(i, 0, trackIndex); AudioLevelsTask::start(clip.parent(), this, index); + KeyframesTask::start(clip.parent()); if (notify) { emit appended(trackIndex, i); emit modified(); @@ -3584,6 +3589,7 @@ void MultitrackModel::load() if (m_tractor) { emit aboutToClose(); AudioLevelsTask::closeAll(); + KeyframesTask::closeAll(); beginResetModel(); delete m_tractor; m_tractor = nullptr; @@ -3633,6 +3639,7 @@ void MultitrackModel::reload(bool asynchronous) emit reloadRequested(); } else { AudioLevelsTask::closeAll(); + KeyframesTask::closeAll(); beginResetModel(); endResetModel(); getAudioLevels(); @@ -3720,6 +3727,7 @@ void MultitrackModel::close() return; emit aboutToClose(); AudioLevelsTask::closeAll(); + KeyframesTask::closeAll(); beginResetModel(); delete m_tractor; m_tractor = nullptr; @@ -3823,10 +3831,11 @@ void MultitrackModel::getAudioLevels() if (playlist.is_valid()) { for (int clipIx = 0; clipIx < playlist.count(); clipIx++) { QScopedPointer clip(playlist.get_clip(clipIx)); - if (clip && clip->is_valid() && !clip->is_blank() - && clip->get_int("audio_index") > -1) { + if (clip && clip->is_valid() && !clip->is_blank()) { QModelIndex index = createIndex(clipIx, 0, trackIx); - AudioLevelsTask::start(clip->parent(), this, index); + KeyframesTask::start(clip->parent()); + if (clip->get_int("audio_index") > -1) + AudioLevelsTask::start(clip->parent(), this, index); } } } diff --git a/src/player.cpp b/src/player.cpp index b9d9864b32..78dd3df509 100644 --- a/src/player.cpp +++ b/src/player.cpp @@ -30,13 +30,23 @@ #include "widgets/statuslabelwidget.h" #include "widgets/timespinbox.h" +#include +#include +#include +#include #include +#include #include +#include "models/keyframestask.h" +#include "shotcut_mlt_properties.h" + #define VOLUME_KNEE (88) #define SEEK_INACTIVE (-1) #define VOLUME_SLIDER_HEIGHT (300) +static constexpr const int SEEK_DEBOUNCE_INTERVAL_MS = 100; +static constexpr const int SEEK_KEYFRAME_THRESHOLD_SECONDS = 2; class NoWheelTabBar : public QTabBar { @@ -73,6 +83,8 @@ Player::Player(QWidget *parent) , m_currentTransport(nullptr) , m_loopStart(-1) , m_loopEnd(-1) + , m_seekTargetPosition(SEEK_INACTIVE) + , m_seekDebounceTimer(nullptr) { setObjectName("Player"); Mlt::Controller::singleton(); @@ -448,6 +460,11 @@ Player::Player(QWidget *parent) }); setFocusPolicy(Qt::StrongFocus); + + m_seekDebounceTimer = new QTimer(this); + m_seekDebounceTimer->setSingleShot(true); + m_seekDebounceTimer->setInterval(SEEK_DEBOUNCE_INTERVAL_MS); + connect(m_seekDebounceTimer, &QTimer::timeout, this, &Player::onSeekDebounceTimerFired); } void Player::connectTransport(const TransportControllable *receiver) @@ -884,11 +901,106 @@ void Player::stop() Actions["playerPlayPauseAction"]->setIcon(m_playIcon); } +static int precedingKeyframe(const QVector &kf, int position) +{ + // kf is sorted ascending; return the largest value <= position + auto it = std::lower_bound(kf.constBegin(), kf.constEnd(), position); + if (it != kf.constEnd() && *it == position) { + return position; + } + if (it == kf.constBegin()) { + return 0; + } + --it; + return *it; +} + +// Returns the preceding keyframe position in the same coordinate system as +// `position`. For a tractor (timeline) producer the topmost video track is +// searched; for a single clip the clip's own keyframe list is used. Returns +// `position` when no usable keyframe data is found (treat as direct seek). +static int precedingKeyframeForProducer(Mlt::Producer *producer, int position) +{ + if (!producer || !producer->is_valid()) + return position; + if (producer->type() == mlt_service_tractor_type) { + Mlt::Tractor tractor(*producer); + // Iterate from the topmost track downward to find the first video clip + // with keyframe data at this position. + for (int i = tractor.count() - 1; i >= 0; i--) { + QScopedPointer track(tractor.track(i)); + if (!track || !track->is_valid() || track->type() != mlt_service_playlist_type) + continue; + if (track->get_int("hide") == 1) // audio-only track + continue; + Mlt::Playlist playlist(*track); + int clipIndex = playlist.get_clip_index_at(position); + QScopedPointer info(playlist.clip_info(clipIndex)); + if (!info || !info->producer || !info->producer->is_valid()) + continue; + if (!qstrcmp(info->producer->get("mlt_service"), "blank")) + continue; + auto *kfMap = static_cast> *>( + info->producer->get_data(kKeyframesProperty)); + if (!kfMap || kfMap->isEmpty()) + continue; // no keyframe data for this clip; try next track + // Select the keyframe list for the clip's active video stream. + int vi = info->producer->get_int("video_index"); + auto kfIt = kfMap->find(vi); + if (kfIt == kfMap->end()) + kfIt = kfMap->begin(); + if (kfIt->isEmpty() || kfIt->first() == kIntraOnlySentinel) + continue; // intra-only or no data; try next track + // Convert tractor position → producer position → K → tractor position + const int producerPos = position - info->start + info->frame_in; + const int K_producer = precedingKeyframe(*kfIt, producerPos); + if (K_producer == producerPos) + return position; // already at a keyframe + return qMax(K_producer - info->frame_in + info->start, info->start); + } + return position; + } + // Single clip (source tab) + auto *kfMap = static_cast> *>(producer->get_data(kKeyframesProperty)); + if (!kfMap || kfMap->isEmpty()) + return position; + // Select the keyframe list for the producer's active video stream. + int vi = producer->get_int("video_index"); + auto kfIt = kfMap->find(vi); + if (kfIt == kfMap->end()) + kfIt = kfMap->begin(); + if (kfIt->isEmpty() || kfIt->first() == kIntraOnlySentinel) + return position; + return precedingKeyframe(*kfIt, position); +} + void Player::seek(int position) { if (m_isSeekable) { - if (position >= 0) { - emit seeked(qMin(position, MLT.isMultitrack() ? m_duration : m_duration - 1)); + if (position >= 0 && MLT.producer() && MLT.producer()->is_valid()) { + auto clampedPos = qMin(position, MLT.isMultitrack() ? m_duration : m_duration - 1); + emit positionRequested(clampedPos); + // Two-phase seek: immediately seek to the preceding keyframe K for quick visual + // feedback. Then debounce seeking to the precise position until scrubbing settles. + if (qAbs(position - m_position) + > SEEK_KEYFRAME_THRESHOLD_SECONDS * qRound(MLT.profile().fps())) { + const int K = precedingKeyframeForProducer(MLT.producer(), clampedPos); + if (K != clampedPos) { + emit seeked(K); + // Debounce seeking to the precise position until scrubbing settles. + m_seekTargetPosition = clampedPos; + m_seekDebounceTimer->start(); + if (Settings.playerPauseAfterSeek()) { + Actions["playerPlayPauseAction"]->setIcon(m_playIcon); + m_playPosition = std::numeric_limits::max(); + } + return; + } + } + // Intra-only or already at a keyframe, so seek directly without debouncing. + m_seekDebounceTimer->stop(); + m_seekTargetPosition = SEEK_INACTIVE; + emit seeked(clampedPos); } } if (Settings.playerPauseAfterSeek()) { @@ -897,6 +1009,29 @@ void Player::seek(int position) } } +void Player::onSeekDebounceTimerFired() +{ + LOG_DEBUG() << "debounced seek to target position" << m_seekTargetPosition; + if (m_seekTargetPosition == SEEK_INACTIVE) + return; + if (!MLT.producer() || !MLT.producer()->is_valid()) { + m_seekTargetPosition = SEEK_INACTIVE; + return; + } + // Re-seek to the preceding keyframe K so that onFrameDisplayed can chain + // to the precise target P once K is actually on screen. + const int K = precedingKeyframeForProducer(MLT.producer(), m_seekTargetPosition); + if (K != m_seekTargetPosition) { + emit seeked(K); // m_seekTargetPosition stays = P + return; + } + // P is already a keyframe (or no keyframe data): seek directly. + const int target = m_seekTargetPosition; + m_seekTargetPosition = SEEK_INACTIVE; + LOG_DEBUG() << "seeking to target frame" << target; + emit seeked(target); +} + void Player::reset() { m_scrubber->setMarkers(QList()); @@ -1016,6 +1151,14 @@ void Player::onFrameDisplayed(const SharedFrame &frame) onProducerOpened(false); } int position = frame.get_position(); + // Phase 2 of two-phase seek: K is now on screen and the debounce timer has + // expired (user stopped scrubbing), so chain to the precise target P. + if (m_seekTargetPosition != SEEK_INACTIVE && !m_seekDebounceTimer->isActive() + && position == precedingKeyframeForProducer(MLT.producer(), m_seekTargetPosition)) { + const int P = m_seekTargetPosition; + m_seekTargetPosition = SEEK_INACTIVE; + emit seeked(P); + } bool loop = position >= (m_loopEnd - 1) && Actions["playerLoopAction"]->isChecked(); if (position > MLT.producer()->get_length()) { position = MLT.producer()->get_length(); @@ -1023,8 +1166,12 @@ void Player::onFrameDisplayed(const SharedFrame &frame) if (position <= m_duration) { m_position = position; m_requestedPosition = position; + // While a precise seek is pending show the target so the spinner does not + // jump to the keyframe position. + const int displayPos = (m_seekTargetPosition != SEEK_INACTIVE) ? m_seekTargetPosition + : position; m_positionSpinner->blockSignals(true); - m_positionSpinner->setValue(position); + m_positionSpinner->setValue(displayPos); m_positionSpinner->blockSignals(false); m_scrubber->onSeek(position); if (m_playPosition < m_previousOut && m_position >= m_previousOut && !loop) { diff --git a/src/player.h b/src/player.h index d413f5886b..2d1b9ec7d1 100644 --- a/src/player.h +++ b/src/player.h @@ -22,6 +22,7 @@ #include #include +#include #include class DockToolBar; @@ -72,6 +73,7 @@ class Player : public QWidget void paused(int position); void stopped(); void seeked(int position); + void positionRequested(int position); void rewound(bool forceChangeDirection); void fastForwarded(bool forceChangeDirection); void previousSought(int currentPosition); @@ -176,6 +178,8 @@ public slots: QHBoxLayout *m_toolRow1; QHBoxLayout *m_toolRow2; int m_requestedPosition{0}; + QTimer *m_seekDebounceTimer; + int m_seekTargetPosition; private slots: void updateSelection(); @@ -188,6 +192,7 @@ private slots: void toggleZoom(bool checked); void onGridToggled(); void toggleGrid(bool checked); + void onSeekDebounceTimerFired(); void onStatusFinished(); void onOffsetChanged(const QPoint &offset); }; diff --git a/src/qml/views/timeline/timeline.qml b/src/qml/views/timeline/timeline.qml index fb302eba23..956529217b 100644 --- a/src/qml/views/timeline/timeline.qml +++ b/src/qml/views/timeline/timeline.qml @@ -688,11 +688,11 @@ Rectangle { Rectangle { id: cursor - visible: timeline.position > -1 + visible: timeline.requestedPosition > -1 color: activePalette.text width: 1 height: root.height - horizontalScrollBar.height - x: timeline.position * multitrack.scaleFactor - tracksFlickable.contentX + x: timeline.requestedPosition * multitrack.scaleFactor - tracksFlickable.contentX y: 0 } diff --git a/src/shotcut_mlt_properties.h b/src/shotcut_mlt_properties.h index 459a962940..3a9ae53142 100644 --- a/src/shotcut_mlt_properties.h +++ b/src/shotcut_mlt_properties.h @@ -88,6 +88,7 @@ /* Internal only */ #define kAudioLevelsProperty "_shotcut:audio-levels" +#define kKeyframesProperty "_shotcut:keyframes" #define kBackgroundCaptureProperty "_shotcut:bgcapture" #define kPlaylistIndexProperty "_shotcut:playlistIndex" #define kPlaylistStartProperty "_shotcut:playlistStart"