/* * Copyright (c) 2011-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 "videowidget.h" #include "Logger.h" #include "dialogs/durationdialog.h" #include "mainwindow.h" #include "qmltypes/qmlfilter.h" #include "qmltypes/qmlutilities.h" #include "settings.h" #include #include #include #include #include #include #include using namespace Mlt; VideoWidget::VideoWidget(QObject *parent) : QQuickWidget(QmlUtilities::sharedEngine(), (QWidget *) parent) , Controller() , m_grid(0) , m_initSem(0) , m_isInitialized(false) , m_frameRenderer(nullptr) , m_zoom(0.0f) , m_offset(QPoint(0, 0)) , m_snapToGrid(true) , m_scrubAudio(false) , m_maxTextureSize(4096) , m_hideVui(false) { LOG_DEBUG() << "begin"; setAttribute(Qt::WA_AcceptTouchEvents); setResizeMode(QQuickWidget::SizeRootObjectToView); setClearColor(palette().window().color()); QDir importPath = QmlUtilities::qmlDir(); importPath.cd("modules"); engine()->addImportPath(importPath.path()); QmlUtilities::setCommonProperties(rootContext()); rootContext()->setContextProperty("video", this); m_refreshTimer.setInterval(10); m_refreshTimer.setSingleShot(true); if (Settings.playerGPU()) m_glslManager.reset(new Filter(profile(), "glsl.manager")); if ((m_glslManager && !m_glslManager->is_valid())) { m_glslManager.reset(); } connect(quickWindow(), &QQuickWindow::visibilityChanged, this, &VideoWidget::setBlankScene, Qt::QueuedConnection); connect(&m_refreshTimer, &QTimer::timeout, this, &VideoWidget::onRefreshTimeout); connect(this, &VideoWidget::rectChanged, this, &VideoWidget::zoomChanged); LOG_DEBUG() << "end"; } VideoWidget::~VideoWidget() { LOG_DEBUG() << "begin"; stop(); if (m_frameRenderer && m_frameRenderer->isRunning()) { m_frameRenderer->quit(); m_frameRenderer->wait(); m_frameRenderer->deleteLater(); } LOG_DEBUG() << "end"; } void VideoWidget::initialize() { LOG_DEBUG() << "begin"; m_frameRenderer = new FrameRenderer(); connect(m_frameRenderer, &FrameRenderer::frameDisplayed, this, &VideoWidget::onFrameDisplayed, Qt::QueuedConnection); connect(m_frameRenderer, &FrameRenderer::frameDisplayed, this, &VideoWidget::frameDisplayed, Qt::QueuedConnection); connect(m_frameRenderer, SIGNAL(imageReady()), SIGNAL(imageReady())); m_initSem.release(); m_isInitialized = true; LOG_DEBUG() << "end"; } void VideoWidget::renderVideo() {} void VideoWidget::setBlankScene() { quickWindow()->setColor(palette().window().color()); setSource(QmlUtilities::blankVui()); m_savedQmlSource.clear(); } void VideoWidget::resizeVideo(int width, int height) { double x, y, w, h; double this_aspect = (double) width / height; double video_aspect = profile().dar(); // Special case optimisation to negate odd effect of sample aspect ratio // not corresponding exactly with image resolution. if ((int) (this_aspect * 1000) == (int) (video_aspect * 1000)) { w = width; h = height; } // Use OpenGL to normalise sample aspect ratio else if (height * video_aspect > width) { w = width; h = width / video_aspect; } else { w = height * video_aspect; h = height; } x = (width - w) / 2.0; y = (height - h) / 2.0; m_rect.setRect(x, y, w, h); emit rectChanged(); } void VideoWidget::resizeEvent(QResizeEvent *event) { QQuickWidget::resizeEvent(event); resizeVideo(event->size().width(), event->size().height()); } void VideoWidget::onRefreshTimeout() { Controller::refreshConsumer(m_scrubAudio); m_scrubAudio = false; } void VideoWidget::mousePressEvent(QMouseEvent *event) { QQuickWidget::mousePressEvent(event); if (event->isAccepted()) return; if (event->button() == Qt::LeftButton) m_dragStart = event->pos(); else if (event->button() == Qt::MiddleButton) m_mousePosition = event->pos(); if (MLT.isClip()) emit dragStarted(); } void VideoWidget::mouseMoveEvent(QMouseEvent *event) { QQuickWidget::mouseMoveEvent(event); if (event->isAccepted()) return; if (event->buttons() & Qt::MiddleButton) { emit offsetChanged(m_offset + m_mousePosition - event->pos()); m_mousePosition = event->pos(); return; } if (event->modifiers() == (Qt::ShiftModifier | Qt::AltModifier) && m_producer) { emit seekTo(m_producer->get_length() * event->position().x() / width()); return; } if (!(event->buttons() & Qt::LeftButton)) return; if (m_dragStart.isNull()) return; if ((event->pos() - m_dragStart).manhattanLength() < QApplication::startDragDistance()) return; // Reset the drag point to prevent repeating drag actions. m_dragStart.setX(0); m_dragStart.setY(0); if (!MLT.producer()) return; if (MLT.isMultitrack() || MLT.isPlaylist()) { MAIN.showStatusMessage(tr("You cannot drag from Project.")); return; } else if (!MLT.isSeekableClip()) { MAIN.showStatusMessage(tr("You cannot drag a non-seekable source")); return; } // Cannot show a DurationDialog during mouse drag // This is usually MLT.isLiveProducer(), but that checks for > 1 week, // which is too long for the timeline. if (m_producer->get_playtime() > qRound(profile().fps() * 24 * 3600)) { m_producer->set_in_and_out(0, profile().fps() * 60 - 1); } QDrag *drag = new QDrag(this); QMimeData *mimeData = new QMimeData; mimeData->setData(Mlt::XmlMimeType, MLT.XML().toUtf8()); drag->setMimeData(mimeData); mimeData->setText(QString::number(MLT.producer()->get_playtime())); if (m_frameRenderer && m_frameRenderer->getDisplayFrame().is_valid()) { Mlt::Frame displayFrame(m_frameRenderer->getDisplayFrame().clone(false, true)); QImage displayImage = MLT.image(&displayFrame, 45 * MLT.profile().dar(), 45).scaledToHeight(45); drag->setPixmap(QPixmap::fromImage(displayImage)); } drag->setHotSpot(QPoint(0, 0)); drag->exec(Qt::CopyAction); } void VideoWidget::keyPressEvent(QKeyEvent *event) { QQuickWidget::keyPressEvent(event); if (event->isAccepted()) return; MAIN.keyPressEvent(event); } bool VideoWidget::event(QEvent *event) { bool result = QQuickWidget::event(event); if (event->type() == QEvent::PaletteChange && m_sharedFrame.is_valid()) onFrameDisplayed(m_sharedFrame); return result; } int VideoWidget::setProducer(Mlt::Producer *producer, bool isMulti) { int error = Controller::setProducer(producer, isMulti); if (!error) { error = reconfigure(isMulti); if (!error) { // The profile display aspect ratio may have changed. resizeVideo(width(), height()); } } return error; } void VideoWidget::createThread(RenderThread **thread, thread_function_t function, void *data) { #ifdef Q_OS_WIN // On Windows, MLT event consumer-thread-create is fired from the Qt main thread. while (!m_isInitialized) QCoreApplication::processEvents(); #else if (!m_isInitialized) { m_initSem.acquire(); } #endif if (!m_renderThread) { m_renderThread.reset(new RenderThread(function, data)); (*thread) = m_renderThread.get(); (*thread)->start(); } else { m_renderThread->start(); } } static void onThreadCreate(mlt_properties owner, VideoWidget *self, mlt_event_data data) { Q_UNUSED(owner) auto threadData = (mlt_event_data_thread *) Mlt::EventData(data).to_object(); if (threadData) { self->createThread((RenderThread **) threadData->thread, threadData->function, threadData->data); } } static void onThreadJoin(mlt_properties owner, VideoWidget *self, mlt_event_data data) { Q_UNUSED(owner) Q_UNUSED(self) auto threadData = (mlt_event_data_thread *) Mlt::EventData(data).to_object(); if (threadData && threadData->thread) { auto renderThread = (RenderThread *) *threadData->thread; if (renderThread) { renderThread->quit(); renderThread->wait(); } } } void VideoWidget::startGlsl() { if (m_glslManager) { m_glslManager->fire_event("init glsl"); if (!m_glslManager->get_int("glsl_supported")) { m_glslManager.reset(); // Need to destroy MLT global reference to prevent filters from trying to use GPU. mlt_properties_clear(mlt_global_properties(), "glslManager"); emit gpuNotSupported(); } else { emit started(); } } } static void onThreadStarted(mlt_properties owner, VideoWidget *self) { Q_UNUSED(owner) self->startGlsl(); } void VideoWidget::stopGlsl() { //TODO This is commented out for now because it is causing crashes. //Technically, this should be the correct thing to do, but it appears //some changes in the 15.01 and 15.03 releases have created regression //with respect to restarting the consumer in GPU mode. // m_glslManager->fire_event("close glsl"); } static void onThreadStopped(mlt_properties owner, VideoWidget *self) { Q_UNUSED(owner) self->stopGlsl(); } int VideoWidget::reconfigure(bool isMulti) { int error = 0; // use SDL for audio, OpenGL for video QString serviceName = property("mlt_service").toString(); if (!m_consumer || !m_consumer->is_valid()) { if (serviceName.isEmpty()) { m_consumer.reset(new Mlt::FilteredConsumer(previewProfile(), "sdl2_audio")); if (m_consumer->is_valid()) serviceName = "sdl2_audio"; else serviceName = "rtaudio"; m_consumer.reset(); } if (isMulti) m_consumer.reset(new Mlt::FilteredConsumer(previewProfile(), "multi")); else m_consumer.reset( new Mlt::FilteredConsumer(previewProfile(), serviceName.toLatin1().constData())); m_threadStartEvent.reset(); m_threadStopEvent.reset(); m_threadCreateEvent.reset(); m_threadJoinEvent.reset(); } if (m_consumer->is_valid()) { // Connect the producer to the consumer - tell it to "run" later if (m_producer && m_producer->is_valid()) m_consumer->connect(*m_producer); // Make an event handler for when a frame's image should be displayed m_consumer->listen("consumer-frame-show", this, (mlt_listener) on_frame_show); m_consumer->set("real_time", MLT.realTime()); m_consumer->set("scale", double(Settings.playerPreviewScale()) / MLT.profile().height()); const int processingMode = property("processing_mode").toInt(); const bool isDeckLinkHLG = serviceName.startsWith("decklink") && property("decklinkGamma").toInt() == 1; switch (processingMode) { case ShotcutSettings::Native10Cpu: case ShotcutSettings::Linear10Cpu: m_consumer->set("mlt_image_format", "rgba64"); break; case ShotcutSettings::Linear10GpuCpu: m_consumer->set("mlt_image_format", isDeckLinkHLG ? "yuv444p10" : "rgba64"); break; default: // Native8Cpu m_consumer->set("mlt_image_format", serviceName.startsWith("decklink") ? "yuv422" : "yuv420p"); break; } m_consumer->set("channels", property("audio_channels").toInt()); if (property("audio_channels").toInt() == 4) { m_consumer->set("channel_layout", "quad"); } else { m_consumer->set("channel_layout", "auto"); } switch (MLT.profile().colorspace()) { case 601: case 170: m_consumer->set("color_trc", "smpte170m"); break; case 240: m_consumer->set("color_trc", "smpte240m"); break; case 470: m_consumer->set("color_trc", "bt470bg"); break; case 2020: if (isDeckLinkHLG) { m_consumer->set("color_trc", "arib-std-b67"); } else { m_consumer->clear("color_trc"); } break; default: m_consumer->set("color_trc", "bt709"); break; } if (processingMode == ShotcutSettings::Linear10Cpu || (processingMode == ShotcutSettings::Linear10GpuCpu && property("decklinkGamma").toInt() != 1)) { m_consumer->set("mlt_color_trc", "linear"); } else { m_consumer->clear("mlt_color_trc"); } if (isMulti) { m_consumer->set("terminate_on_pause", 0); m_consumer->set("0", serviceName.toLatin1().constData()); if (!profile().progressive()) m_consumer->set("0.progressive", property("progressive").toBool()); m_consumer->set("0.rescale", property("rescale").toString().toLatin1().constData()); m_consumer->set("0.deinterlacer", property("deinterlacer").toString().toLatin1().constData()); m_consumer->set("0.buffer", qMax(25, qRound(profile().fps()))); m_consumer->set("0.prefill", 8); m_consumer->set("0.drop_max", qRound(profile().fps() / 4.0)); if (property("keyer").isValid()) m_consumer->set("0.keyer", property("keyer").toInt()); m_consumer->set("0.video_delay", Settings.playerVideoDelayMs()); } else { if (!profile().progressive()) m_consumer->set("progressive", property("progressive").toBool()); m_consumer->set("rescale", property("rescale").toString().toLatin1().constData()); m_consumer->set("deinterlacer", property("deinterlacer").toString().toLatin1().constData()); m_consumer->set("buffer", qMax(25, qRound(profile().fps()))); m_consumer->set("prefill", 8); m_consumer->set("drop_max", qRound(profile().fps() / 4.0)); if (property("keyer").isValid()) m_consumer->set("keyer", property("keyer").toInt()); m_consumer->set("video_delay", Settings.playerVideoDelayMs()); } if (m_glslManager) { if (!m_threadCreateEvent) m_threadCreateEvent.reset(m_consumer->listen("consumer-thread-create", this, (mlt_listener) onThreadCreate)); if (!m_threadJoinEvent) m_threadJoinEvent.reset( m_consumer->listen("consumer-thread-join", this, (mlt_listener) onThreadJoin)); if (!m_threadStartEvent) m_threadStartEvent.reset(m_consumer->listen("consumer-thread-started", this, (mlt_listener) onThreadStarted)); if (!m_threadStopEvent) m_threadStopEvent.reset(m_consumer->listen("consumer-thread-stopped", this, (mlt_listener) onThreadStopped)); } else { emit started(); } } else { // Cleanup on error error = 2; Controller::closeConsumer(); Controller::close(); } return error; } void VideoWidget::refreshConsumer(bool scrubAudio) { scrubAudio |= isPaused() ? scrubAudio : Settings.playerScrubAudio(); m_scrubAudio |= scrubAudio; m_refreshTimer.start(); } QPoint VideoWidget::offset() const { if (m_zoom == 0.0) { return QPoint(0, 0); } else { return QPoint(m_offset.x() - (MLT.profile().width() * m_zoom - width()) / 2, m_offset.y() - (MLT.profile().height() * m_zoom - height()) / 2); } } QImage VideoWidget::image() const { SharedFrame frame = m_frameRenderer->getDisplayFrame(); if (frame.is_valid()) { const uint8_t *image = frame.get_image(mlt_image_rgba); if (image) { int width = frame.get_image_width(); int height = frame.get_image_height(); QImage temp(image, width, height, QImage::Format_RGBA8888); return temp.copy(); } } return QImage(); } bool VideoWidget::imageIsProxy() const { bool isProxy = false; SharedFrame frame = m_frameRenderer->getDisplayFrame(); if (frame.is_valid()) { Mlt::Producer *frameProducer = frame.get_original_producer(); if (frameProducer && frameProducer->is_valid() && frameProducer->get_int(kIsProxyProperty)) { isProxy = true; } delete frameProducer; } return isProxy; } void VideoWidget::requestImage() const { m_frameRenderer->requestImage(); } void VideoWidget::toggleVuiDisplay() { m_hideVui = !m_hideVui; refreshConsumer(); } void VideoWidget::onFrameDisplayed(const SharedFrame &frame) { m_mutex.lock(); m_sharedFrame = frame; m_mutex.unlock(); bool isVui = frame.get_int(kShotcutVuiMetaProperty) && !m_hideVui; if (!isVui && source() != QmlUtilities::blankVui()) { m_savedQmlSource = source(); setSource(QmlUtilities::blankVui()); } else if (isVui && !m_savedQmlSource.isEmpty() && source() != m_savedQmlSource) { setSource(m_savedQmlSource); } quickWindow()->update(); } void VideoWidget::setGrid(int grid) { m_grid = grid; emit gridChanged(); quickWindow()->update(); } void VideoWidget::setZoom(float zoom) { m_zoom = zoom; emit zoomChanged(); // Reset the VUI control setSource(source()); quickWindow()->update(); } void VideoWidget::setOffsetX(int x) { m_offset.setX(x); emit offsetChanged(); quickWindow()->update(); } void VideoWidget::setOffsetY(int y) { m_offset.setY(y); emit offsetChanged(); quickWindow()->update(); } void VideoWidget::setCurrentFilter(QmlFilter *filter, QmlMetadata *meta) { m_hideVui = false; if (meta && meta->type() == QmlMetadata::Filter && QFile::exists(meta->vuiFilePath().toLocalFile())) { filter->producer().set(kShotcutVuiMetaProperty, 1); rootContext()->setContextProperty("filter", filter); setSource(meta->vuiFilePath()); refreshConsumer(); } else { setBlankScene(); } } void VideoWidget::setSnapToGrid(bool snap) { m_snapToGrid = snap; emit snapToGridChanged(); } // MLT consumer-frame-show event handler void VideoWidget::on_frame_show(mlt_consumer, VideoWidget *widget, mlt_event_data data) { auto frame = Mlt::EventData(data).to_frame(); if (frame.is_valid() && frame.get_int("rendered")) { int timeout = (widget->consumer()->get_int("real_time") > 0) ? 0 : 1000; if (widget->m_frameRenderer && widget->m_frameRenderer->semaphore()->tryAcquire(1, timeout)) { QMetaObject::invokeMethod(widget->m_frameRenderer, "showFrame", Qt::QueuedConnection, Q_ARG(Mlt::Frame, frame)); } else if (!Settings.playerRealtime()) { LOG_WARNING() << "VideoWidget dropped frame" << frame.get_position(); } } } RenderThread::RenderThread(thread_function_t function, void *data) : QThread{nullptr} , m_function{function} , m_data{data} , m_context{new QOpenGLContext} , m_surface{new QOffscreenSurface} { QSurfaceFormat format; format.setProfile(QSurfaceFormat::CoreProfile); format.setMajorVersion(3); format.setMinorVersion(2); format.setDepthBufferSize(0); format.setStencilBufferSize(0); m_context->setFormat(format); m_context->create(); m_context->moveToThread(this); m_surface->setFormat(format); m_surface->create(); } RenderThread::~RenderThread() { m_surface->destroy(); } void RenderThread::run() { Q_ASSERT(m_context->isValid()); m_context->makeCurrent(m_surface.get()); m_function(m_data); m_context->doneCurrent(); } FrameRenderer::FrameRenderer() : QThread(nullptr) , m_semaphore(3) , m_imageRequested(false) { setObjectName("FrameRenderer"); moveToThread(this); start(); } FrameRenderer::~FrameRenderer() {} void FrameRenderer::showFrame(Mlt::Frame frame) { m_displayFrame = SharedFrame(frame); emit frameDisplayed(m_displayFrame); if (m_imageRequested) { m_imageRequested = false; emit imageReady(); } m_semaphore.release(); } void FrameRenderer::requestImage() { m_imageRequested = true; } SharedFrame FrameRenderer::getDisplayFrame() { return m_displayFrame; }