File indexing completed on 2024-05-19 04:29:18
0001 /* This file is part of the KDE project 0002 SPDX-FileCopyrightText: 2022 Emmet O'Neill <emmetoneill.pdx@gmail.com> 0003 SPDX-FileCopyrightText: 2022 Eoin O'Neill <eoinoneill1991@gmail.com> 0004 0005 SPDX-License-Identifier: LGPL-2.0-or-later 0006 */ 0007 0008 #include "KisPlaybackEngineMLT.h" 0009 0010 #include <QMap> 0011 0012 #include <QElapsedTimer> 0013 #include <QWaitCondition> 0014 0015 #include "kis_canvas2.h" 0016 #include "KisCanvasAnimationState.h" 0017 #include "kis_image_animation_interface.h" 0018 #include "kis_raster_keyframe_channel.h" 0019 #include "kis_signal_compressor_with_param.h" 0020 #include "animation/KisFrameDisplayProxy.h" 0021 #include "KisViewManager.h" 0022 #include "kis_onion_skin_compositor.h" 0023 0024 #include <mlt++/Mlt.h> 0025 #include <mlt++/MltConsumer.h> 0026 #include <mlt++/MltFrame.h> 0027 #include <mlt++/MltFilter.h> 0028 #include <mlt-7/framework/mlt_service.h> 0029 0030 #include "KisRollingMeanAccumulatorWrapper.h" 0031 #include "KisRollingSumAccumulatorWrapper.h" 0032 0033 #ifdef Q_OS_ANDROID 0034 #include <KisAndroidFileProxy.h> 0035 #endif 0036 0037 #include "kis_debug.h" 0038 0039 #include "KisMLTProducerKrita.h" 0040 0041 0042 const float SCRUB_AUDIO_SECONDS = 0.128f; 0043 0044 struct KisPlaybackEngineMLT::FrameWaitingInterface { 0045 bool renderingAllowed {false}; 0046 bool waitingForFrame {false}; 0047 QMutex renderingControlMutex; 0048 QWaitCondition renderingWaitCondition; 0049 }; 0050 0051 namespace { 0052 0053 struct FrameRenderingStats 0054 { 0055 static constexpr int frameStatsWindow = 50; 0056 0057 KisRollingMeanAccumulatorWrapper averageFrameDuration {frameStatsWindow}; 0058 KisRollingSumAccumulatorWrapper droppedFramesCount {frameStatsWindow}; 0059 int lastRenderedFrame {-1}; 0060 QElapsedTimer timeSinceLastFrame; 0061 0062 void reset() { 0063 averageFrameDuration.reset(frameStatsWindow); 0064 droppedFramesCount.reset(frameStatsWindow); 0065 lastRenderedFrame = -1; 0066 } 0067 }; 0068 0069 } 0070 /** 0071 * This static funciton responds to MLT consumer requests for frames. This may 0072 * continue to be called even when playback is stopped due to it running 0073 * simultaneously in a separate thread. 0074 */ 0075 static void mltOnConsumerFrameShow(mlt_consumer c, void* p_self, mlt_frame p_frame) { 0076 KisPlaybackEngineMLT* self = static_cast<KisPlaybackEngineMLT*>(p_self); 0077 Mlt::Frame frame(p_frame); 0078 Mlt::Consumer consumer(c); 0079 const int position = frame.get_position(); 0080 0081 KisPlaybackEngineMLT::FrameWaitingInterface *iface = self->frameWaitingInterface(); 0082 0083 /** 0084 * This function is called from the non-gui thread owned by MLT, 0085 * so we should wait until the frame would be really rendered. 0086 * This way MLT will have information about frame rendering speed 0087 * and will be able to drop frames accordingly. 0088 * 0089 * NOTE: we cannot use BlockingQueuedConnection here because it 0090 * would deadlock on any stream property change, when the the GUI 0091 * thread would call consumer->stop(). 0092 * 0093 */ 0094 QMutexLocker l(&iface->renderingControlMutex); 0095 0096 if (!iface->renderingAllowed) return; 0097 0098 KIS_SAFE_ASSERT_RECOVER_RETURN(!iface->waitingForFrame); 0099 iface->waitingForFrame = true; 0100 0101 emit self->sigChangeActiveCanvasFrame(position); 0102 0103 while (iface->renderingAllowed && iface->waitingForFrame) { 0104 iface->renderingWaitCondition.wait(&iface->renderingControlMutex); 0105 } 0106 } 0107 0108 //===== 0109 0110 struct KisPlaybackEngineMLT::Private { 0111 0112 Private(KisPlaybackEngineMLT* p_self) 0113 : m_self(p_self) 0114 , playbackSpeed(1.0) 0115 , mute(false) 0116 { 0117 // Initialize MLT... 0118 repository.reset(Mlt::Factory::init()); 0119 0120 // Register our backend plugin 0121 registerKritaMLTProducer(repository.data()); 0122 0123 profile.reset(new Mlt::Profile()); 0124 profile->set_frame_rate(24, 1); 0125 0126 { 0127 std::function<void (int)> callback(std::bind(&Private::pushAudio, this, std::placeholders::_1)); 0128 sigPushAudioCompressor.reset( 0129 new KisSignalCompressorWithParam<int>(1000 * SCRUB_AUDIO_SECONDS, callback, KisSignalCompressor::FIRST_ACTIVE) 0130 ); 0131 } 0132 0133 { 0134 std::function<void (const double)> callback(std::bind(&KisPlaybackEngineMLT::throttledSetSpeed, m_self, std::placeholders::_1)); 0135 sigSetPlaybackSpeed.reset( 0136 new KisSignalCompressorWithParam<double>(100, callback, KisSignalCompressor::POSTPONE) 0137 ); 0138 } 0139 0140 initializeConsumers(); 0141 } 0142 0143 ~Private() { 0144 cleanupConsumers(); 0145 repository.reset(); 0146 Mlt::Factory::close(); 0147 } 0148 0149 void pushAudio(int frame) { 0150 0151 if (pushConsumer->is_stopped() || !m_self->activeCanvas()) { 0152 return; 0153 } 0154 0155 QSharedPointer<Mlt::Producer> activeProducer = canvasProducers[m_self->activeCanvas()]; 0156 if (activePlaybackMode() == PLAYBACK_PUSH && activeProducer) { 0157 const int SCRUB_AUDIO_WINDOW = profile->frame_rate_num() * SCRUB_AUDIO_SECONDS; 0158 for (int i = 0; i < SCRUB_AUDIO_WINDOW; i++ ) { 0159 Mlt::Frame* f = activeProducer->get_frame(frame + i ); 0160 pushConsumer->push(*f); 0161 delete f; 0162 } 0163 0164 // It turns out that get_frame actually seeks to the frame too, 0165 // Not having this last seek will cause unexpected "jumps" at 0166 // the beginning of playback... 0167 activeProducer->seek(frame); 0168 } 0169 } 0170 0171 void initializeConsumers() { 0172 pushConsumer.reset(new Mlt::PushConsumer(*profile, "sdl2_audio")); 0173 pullConsumer.reset(new Mlt::Consumer(*profile, "sdl2_audio")); 0174 pullConsumerConnection.reset(pullConsumer->listen("consumer-frame-show", m_self, (mlt_listener)mltOnConsumerFrameShow)); 0175 } 0176 0177 void cleanupConsumers() { 0178 if (pullConsumer && !pullConsumer->is_stopped()) { 0179 pullConsumer->stop(); 0180 } 0181 0182 if (pushConsumer && !pushConsumer->is_stopped()) { 0183 pushConsumer->stop(); 0184 } 0185 0186 pullConsumer.reset(); 0187 pushConsumer.reset(); 0188 } 0189 0190 KisCanvas2* activeCanvas() { 0191 return m_self->activeCanvas(); 0192 } 0193 0194 PlaybackMode activePlaybackMode() { 0195 KIS_ASSERT_RECOVER_RETURN_VALUE(activeCanvas(), PLAYBACK_PUSH); 0196 KIS_ASSERT_RECOVER_RETURN_VALUE(activeCanvas()->animationState(), PLAYBACK_PUSH); 0197 return activeCanvas()->animationState()->playbackState() == PlaybackState::PLAYING ? PLAYBACK_PULL : PLAYBACK_PUSH; 0198 } 0199 0200 QSharedPointer<Mlt::Producer> activeProducer() { 0201 KIS_ASSERT_RECOVER_RETURN_VALUE(activeCanvas(), nullptr); 0202 KIS_ASSERT_RECOVER_RETURN_VALUE(canvasProducers.contains(activeCanvas()), nullptr); 0203 return canvasProducers[activeCanvas()]; 0204 } 0205 0206 bool dropFrames() const { 0207 return m_self->dropFrames(); 0208 } 0209 0210 private: 0211 KisPlaybackEngineMLT* m_self; 0212 0213 public: 0214 QScopedPointer<Mlt::Repository> repository; 0215 QScopedPointer<Mlt::Profile> profile; 0216 0217 //MLT PUSH CONSUMER 0218 QScopedPointer<Mlt::Consumer> pullConsumer; 0219 QScopedPointer<Mlt::Event> pullConsumerConnection; 0220 0221 //MLT PULL CONSUMER 0222 QScopedPointer<Mlt::PushConsumer> pushConsumer; 0223 0224 // Map of handles to Mlt producers.. 0225 QMap<KisCanvas2*, QSharedPointer<Mlt::Producer>> canvasProducers; 0226 0227 QScopedPointer<KisSignalCompressorWithParam<int>> sigPushAudioCompressor; 0228 QScopedPointer<KisSignalCompressorWithParam<double>> sigSetPlaybackSpeed; 0229 0230 double playbackSpeed; 0231 bool mute; 0232 0233 FrameWaitingInterface frameWaitingInterface; 0234 FrameRenderingStats frameStats; 0235 }; 0236 0237 //===== 0238 0239 /** 0240 * @brief The StopAndResumeConsumer struct is used to encapsulate optional 0241 * stop-and-then-resume behavior of a consumer. Using RAII, we can stop 0242 * a consumer at construction and simply resume it when it exits scope. 0243 */ 0244 struct KisPlaybackEngineMLT::StopAndResume { 0245 public: 0246 explicit StopAndResume(KisPlaybackEngineMLT::Private* p_d, bool requireFullRestart = false) 0247 : m_d(p_d) 0248 { 0249 KIS_ASSERT(p_d); 0250 0251 0252 { 0253 QMutexLocker l(&m_d->frameWaitingInterface.renderingControlMutex); 0254 m_d->frameWaitingInterface.renderingAllowed = false; 0255 m_d->frameWaitingInterface.renderingWaitCondition.wakeAll(); 0256 } 0257 0258 m_d->pushConsumer->stop(); 0259 m_d->pushConsumer->purge(); 0260 m_d->pullConsumer->stop(); 0261 m_d->pullConsumer->purge(); 0262 m_d->pullConsumer->disconnect_all_producers(); 0263 0264 if (requireFullRestart) { 0265 m_d->cleanupConsumers(); 0266 } 0267 } 0268 0269 ~StopAndResume() { 0270 KIS_ASSERT(m_d); 0271 if (!m_d->pushConsumer || !m_d->pullConsumer) { 0272 m_d->initializeConsumers(); 0273 } 0274 0275 if (m_d->activeCanvas()) { 0276 KisCanvasAnimationState* animationState = m_d->activeCanvas()->animationState(); 0277 KIS_SAFE_ASSERT_RECOVER_RETURN(animationState); 0278 0279 { 0280 QMutexLocker l(&m_d->frameWaitingInterface.renderingControlMutex); 0281 m_d->frameWaitingInterface.renderingAllowed = true; 0282 m_d->frameWaitingInterface.waitingForFrame = false; 0283 0284 m_d->frameWaitingInterface.renderingWaitCondition.wakeAll(); 0285 } 0286 0287 m_d->frameStats.reset(); 0288 0289 { 0290 /** 0291 * Make sure that **all** producer properties are initialized **before** 0292 * the consumers start pulling stuff from the producer. Otherwise we 0293 * can get a race condition with the consumer's read-ahead thread. 0294 */ 0295 0296 KisImageAnimationInterface* animInterface = m_d->activeCanvas()->image()->animationInterface(); 0297 m_d->activeProducer()->set("start_frame", animInterface->activePlaybackRange().start()); 0298 m_d->activeProducer()->set("end_frame", animInterface->activePlaybackRange().end()); 0299 m_d->activeProducer()->set("speed", m_d->playbackSpeed); 0300 const int shouldLimit = m_d->activePlaybackMode() == PLAYBACK_PUSH ? 0 : 1; 0301 m_d->activeProducer()->set("limit_enabled", shouldLimit); 0302 } 0303 0304 if (m_d->activePlaybackMode() == PLAYBACK_PUSH) { 0305 m_d->pushConsumer->set("volume", m_d->mute ? 0.0 : animationState->currentVolume()); 0306 m_d->pushConsumer->start(); 0307 } else { 0308 m_d->pullConsumer->connect_producer(*m_d->activeProducer()); 0309 m_d->pullConsumer->set("volume", m_d->mute ? 0.0 : animationState->currentVolume()); 0310 m_d->pullConsumer->set("real_time", m_d->dropFrames() ? 1 : 0); 0311 m_d->pullConsumer->start(); 0312 } 0313 } 0314 } 0315 0316 private: 0317 Private* m_d; 0318 }; 0319 0320 //===== 0321 0322 KisPlaybackEngineMLT::KisPlaybackEngineMLT(QObject *parent) 0323 : KisPlaybackEngine(parent) 0324 , m_d( new Private(this)) 0325 { 0326 connect(this, &KisPlaybackEngineMLT::sigChangeActiveCanvasFrame, this, &KisPlaybackEngineMLT::throttledShowFrame, Qt::UniqueConnection); 0327 } 0328 0329 KisPlaybackEngineMLT::~KisPlaybackEngineMLT() 0330 { 0331 } 0332 0333 void KisPlaybackEngineMLT::seek(int frameIndex, SeekOptionFlags flags) 0334 { 0335 KIS_ASSERT(activeCanvas() && activeCanvas()->animationState()); 0336 KisCanvasAnimationState* animationState = activeCanvas()->animationState(); 0337 0338 if (m_d->activePlaybackMode() == PLAYBACK_PUSH) { 0339 m_d->canvasProducers[activeCanvas()]->seek(frameIndex); 0340 0341 if (flags & SEEK_PUSH_AUDIO) { 0342 0343 m_d->sigPushAudioCompressor->start(frameIndex); 0344 } 0345 0346 animationState->showFrame(frameIndex, (flags & SEEK_FINALIZE) > 0); 0347 } 0348 } 0349 0350 void KisPlaybackEngineMLT::setupProducer(boost::optional<QFileInfo> file) 0351 { 0352 if (!m_d->canvasProducers.contains(activeCanvas())) { 0353 connect(activeCanvas(), SIGNAL(destroyed(QObject*)), this, SLOT(canvasDestroyed(QObject*))); 0354 } 0355 0356 //First, assign to "count" producer. 0357 m_d->canvasProducers[activeCanvas()] = QSharedPointer<Mlt::Producer>(new Mlt::Producer(*m_d->profile, "krita_play_chunk", "count")); 0358 0359 //If we have a file and the file has a valid producer, use that. Otherwise, stick to our "default" producer. 0360 if (file.has_value()) { 0361 QSharedPointer<Mlt::Producer> producer( 0362 0363 #ifdef Q_OS_ANDROID 0364 new Mlt::Producer(*m_d->profile, 0365 "krita_play_chunk", 0366 KisAndroidFileProxy::getFileFromContentUri(file->absoluteFilePath()).toUtf8().data())); 0367 #else 0368 new Mlt::Producer(*m_d->profile, "krita_play_chunk", file->absoluteFilePath().toUtf8().data())); 0369 #endif 0370 if (producer->is_valid()) { 0371 m_d->canvasProducers[activeCanvas()] = producer; 0372 } else { 0373 // SANITY CHECK: Check that the MLT plugins and resources are where the program expects them to be. 0374 // HINT -- Check krita/main.cc's mlt environment variable setup for appimage. 0375 KIS_SAFE_ASSERT_RECOVER_NOOP(qEnvironmentVariableIsSet("MLT_REPOSITORY")); 0376 KIS_SAFE_ASSERT_RECOVER_NOOP(qEnvironmentVariableIsSet("MLT_PROFILES_PATH")); 0377 KIS_SAFE_ASSERT_RECOVER_NOOP(qEnvironmentVariableIsSet("MLT_PRESETS_PATH")); 0378 qDebug() << "Warning: Invalid MLT producer for file: " << ppVar(file->absoluteFilePath()) << " Falling back to audio-less playback."; 0379 } 0380 } 0381 0382 KisImageAnimationInterface *animInterface = activeCanvas()->image()->animationInterface(); 0383 QSharedPointer<Mlt::Producer> producer = m_d->canvasProducers[activeCanvas()]; 0384 KIS_ASSERT(producer->is_valid()); 0385 KIS_ASSERT(animInterface); 0386 0387 producer->set("start_frame", animInterface->documentPlaybackRange().start()); 0388 producer->set("end_frame", animInterface->documentPlaybackRange().end()); 0389 producer->set("limit_enabled", false); 0390 producer->set("speed", m_d->playbackSpeed); 0391 } 0392 0393 void KisPlaybackEngineMLT::setCanvas(KoCanvasBase *p_canvas) 0394 { 0395 KisCanvas2* canvas = dynamic_cast<KisCanvas2*>(p_canvas); 0396 0397 if (activeCanvas() == canvas) { 0398 return; 0399 } 0400 0401 if (activeCanvas()) { 0402 KisCanvasAnimationState* animationState = activeCanvas()->animationState(); 0403 0404 // Disconnect old canvas, prepare for new one.. 0405 if (animationState) { 0406 this->disconnect(animationState); 0407 animationState->disconnect(this); 0408 } 0409 0410 // Disconnect old image, prepare for new one.. 0411 auto image = activeCanvas()->image(); 0412 if (image && image->animationInterface()) { 0413 this->disconnect(image->animationInterface()); 0414 image->animationInterface()->disconnect(this); 0415 } 0416 } 0417 0418 StopAndResume stopResume(m_d.data(), true); 0419 0420 KisPlaybackEngine::setCanvas(p_canvas); 0421 0422 // Connect new canvas.. 0423 if (activeCanvas()) { 0424 KisCanvasAnimationState* animationState = activeCanvas()->animationState(); 0425 KIS_SAFE_ASSERT_RECOVER_RETURN(animationState); 0426 0427 connect(animationState, &KisCanvasAnimationState::sigPlaybackStateChanged, this, [this](PlaybackState state){ 0428 Q_UNUSED(state); // We don't need the state yet -- we just want to stop and resume playback according to new state info. 0429 QSharedPointer<Mlt::Producer> activeProducer = m_d->canvasProducers[activeCanvas()]; 0430 StopAndResume callbackStopResume(m_d.data()); 0431 }); 0432 0433 connect(animationState, &KisCanvasAnimationState::sigPlaybackMediaChanged, this, [this](){ 0434 KisCanvasAnimationState* animationState = activeCanvas()->animationState(); 0435 if (animationState) { 0436 setupProducer(animationState->mediaInfo()); 0437 } 0438 }); 0439 0440 connect(animationState, &KisCanvasAnimationState::sigPlaybackSpeedChanged, this, [this](qreal value){ 0441 m_d->sigSetPlaybackSpeed->start(value); 0442 }); 0443 m_d->playbackSpeed = animationState->playbackSpeed(); 0444 0445 connect(animationState, &KisCanvasAnimationState::sigAudioLevelChanged, this, &KisPlaybackEngineMLT::setAudioVolume); 0446 0447 auto image = activeCanvas()->image(); 0448 KIS_SAFE_ASSERT_RECOVER_RETURN(image); 0449 0450 // Connect new image.. 0451 connect(image->animationInterface(), &KisImageAnimationInterface::sigFramerateChanged, this, [this](){ 0452 StopAndResume callbackStopResume(m_d.data()); 0453 m_d->profile->set_frame_rate(activeCanvas()->image()->animationInterface()->framerate(), 1); 0454 }); 0455 0456 connect(image->animationInterface(), &KisImageAnimationInterface::sigPlaybackRangeChanged, this, [this](){ 0457 QSharedPointer<Mlt::Producer> producer = m_d->canvasProducers[activeCanvas()]; 0458 auto image = activeCanvas()->image(); 0459 KIS_SAFE_ASSERT_RECOVER_RETURN(image); 0460 producer->set("start_frame", image->animationInterface()->activePlaybackRange().start()); 0461 producer->set("end_frame", image->animationInterface()->activePlaybackRange().end()); 0462 }); 0463 0464 setupProducer(animationState->mediaInfo()); 0465 } 0466 0467 } 0468 0469 void KisPlaybackEngineMLT::unsetCanvas() { 0470 setCanvas(nullptr); 0471 } 0472 0473 void KisPlaybackEngineMLT::canvasDestroyed(QObject *canvas) 0474 { 0475 KIS_SAFE_ASSERT_RECOVER_RETURN(m_d->activeCanvas() != canvas); 0476 0477 /** 0478 * We cannot use QMap::remove here, because the `canvas` is already 0479 * half-destroyed and we cannot up-cast to KisCanvas2 anymore 0480 */ 0481 for (auto it = m_d->canvasProducers.begin(); it != m_d->canvasProducers.end(); ++it) { 0482 if (it.key() == canvas) { 0483 m_d->canvasProducers.erase(it); 0484 break; 0485 } 0486 } 0487 } 0488 0489 void KisPlaybackEngineMLT::throttledShowFrame(const int frame) 0490 { 0491 if (activeCanvas() && activeCanvas()->animationState() && 0492 m_d->activePlaybackMode() == PLAYBACK_PULL ) { 0493 0494 if (m_d->frameStats.lastRenderedFrame < 0) { 0495 m_d->frameStats.timeSinceLastFrame.start(); 0496 } else { 0497 const int droppedFrames = qMax(0, frame - m_d->frameStats.lastRenderedFrame - 1); 0498 m_d->frameStats.averageFrameDuration(m_d->frameStats.timeSinceLastFrame.restart()); 0499 m_d->frameStats.droppedFramesCount(droppedFrames); 0500 } 0501 m_d->frameStats.lastRenderedFrame = frame; 0502 0503 activeCanvas()->animationState()->showFrame(frame); 0504 } 0505 0506 { 0507 QMutexLocker l(&m_d->frameWaitingInterface.renderingControlMutex); 0508 m_d->frameWaitingInterface.waitingForFrame = false; 0509 m_d->frameWaitingInterface.renderingWaitCondition.wakeAll(); 0510 } 0511 } 0512 0513 void KisPlaybackEngineMLT::throttledSetSpeed(const double speed) 0514 { 0515 StopAndResume stopResume(m_d.data(), false); 0516 m_d->playbackSpeed = speed; 0517 } 0518 0519 void KisPlaybackEngineMLT::setAudioVolume(qreal volumeNormalized) 0520 { 0521 if (m_d->mute) { 0522 m_d->pullConsumer->set("volume", 0.0); 0523 m_d->pushConsumer->set("volume", 0.0); 0524 } else { 0525 m_d->pullConsumer->set("volume", volumeNormalized); 0526 m_d->pushConsumer->set("volume", volumeNormalized); 0527 } 0528 } 0529 0530 KisPlaybackEngineMLT::FrameWaitingInterface *KisPlaybackEngineMLT::frameWaitingInterface() 0531 { 0532 return &m_d->frameWaitingInterface; 0533 } 0534 0535 void KisPlaybackEngineMLT::setDropFramesMode(bool value) 0536 { 0537 // restart playback if it was active 0538 StopAndResume r(m_d.data(), false); 0539 0540 KisPlaybackEngine::setDropFramesMode(value); 0541 } 0542 0543 void KisPlaybackEngineMLT::setMute(bool val) 0544 { 0545 KIS_SAFE_ASSERT_RECOVER_RETURN(activeCanvas() && activeCanvas()->animationState()); 0546 KisCanvasAnimationState* animationState = activeCanvas()->animationState(); 0547 0548 qreal currentVolume = animationState->currentVolume(); 0549 m_d->mute = val; 0550 setAudioVolume(currentVolume); 0551 } 0552 0553 bool KisPlaybackEngineMLT::isMute() 0554 { 0555 return m_d->mute; 0556 } 0557 0558 KisPlaybackEngine::PlaybackStats KisPlaybackEngineMLT::playbackStatistics() const 0559 { 0560 KisPlaybackEngine::PlaybackStats stats; 0561 0562 if (activeCanvas() && activeCanvas()->animationState() && 0563 m_d->activePlaybackMode() == PLAYBACK_PULL ) { 0564 0565 const int droppedFrames = m_d->frameStats.droppedFramesCount.rollingSum(); 0566 const int totalFrames = 0567 m_d->frameStats.droppedFramesCount.rollingCount() + 0568 droppedFrames; 0569 0570 stats.droppedFramesPortion = qreal(droppedFrames) / totalFrames; 0571 stats.expectedFps = qreal(activeCanvas()->image()->animationInterface()->framerate()) * m_d->playbackSpeed; 0572 0573 const qreal avgTimePerFrame = m_d->frameStats.averageFrameDuration.rollingMeanSafe(); 0574 stats.realFps = !qFuzzyIsNull(avgTimePerFrame) ? 1000.0 / avgTimePerFrame : 0.0; 0575 0576 } 0577 0578 return stats; 0579 } 0580 0581 0582