From 6ef9b7c8871e0580da85c3ff73723ffca530f750 Mon Sep 17 00:00:00 2001 From: Tobias Hahn Date: Thu, 12 Feb 2026 16:18:05 +0100 Subject: [PATCH 1/3] resources: themes: PhyTheme: Correct file icon Correct the file icon to display the correct icon in the file dialog instead of the .notdef glyph. Signed-off-by: Tobias Hahn --- resources/themes/PhyTheme/PhyTheme.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/themes/PhyTheme/PhyTheme.qml b/resources/themes/PhyTheme/PhyTheme.qml index 38f9e9c..ac7250a 100644 --- a/resources/themes/PhyTheme/PhyTheme.qml +++ b/resources/themes/PhyTheme/PhyTheme.qml @@ -41,7 +41,7 @@ QtObject { readonly property string dotsThreeVertical: "\ue5d4" readonly property string code: "\ue86f" readonly property string cpu: "\ue322" - readonly property string file: "\ue66d" + readonly property string file: "\ue24d" readonly property string folder: "\ue2c7" readonly property string folderOpen: "\ue2c8" readonly property string frameCorners: "\ue3c2" From 25cb8fdfaf7e7eaaddbe734095bcd478bc392c46 Mon Sep 17 00:00:00 2001 From: Tobias Hahn Date: Thu, 12 Feb 2026 16:41:36 +0100 Subject: [PATCH 2/3] Add information on video codecs and conversion of video files Add a page that can be opened in both multimedia pages to display available video codecs for encoding and decoding and whether they are hardware accelerated or not. In the file dialog there is a new column that lists the video codecs that are used in video files. Using the convert button in the file dialog allows the conversion of video files to different formats, codecs and resolutions. Video files using software codecs can be converted to hardware accelerated videos to allow faster playback for example. Signed-off-by: Tobias Hahn --- CMakeLists.txt | 10 +- meson.build | 10 +- qtphy.pro | 10 +- resources/controls/PhyConvertDialog.qml | 366 ++++++++++++++++ resources/controls/PhyFileDialog.qml | 60 +++ resources/controls/PhyToolBar.qml | 66 ++- resources/pages/MultimediaInfo.qml | 95 ++++ resources/pages/multimedia.qml | 26 ++ resources/pages/multimedia_qmlsink.qml | 26 ++ resources/resources.qrc | 2 + resources/themes/PhyTheme/PhyTheme.qml | 1 + src/main.cpp | 4 + src/multimedia_formats.cpp | 547 ++++++++++++++++++++++++ src/multimedia_formats.hpp | 77 ++++ 14 files changed, 1265 insertions(+), 35 deletions(-) create mode 100644 resources/controls/PhyConvertDialog.qml create mode 100644 resources/pages/MultimediaInfo.qml create mode 100644 src/multimedia_formats.cpp create mode 100644 src/multimedia_formats.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 5b9402d..302fdbb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,6 +9,8 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTORCC ON) find_package(Qt6 REQUIRED COMPONENTS Core Qml Quick Gui DBus Multimedia) +find_package(PkgConfig REQUIRED) +pkg_check_modules(gstreamer REQUIRED IMPORTED_TARGET gstreamer-1.0 gstreamer-pbutils-1.0) list(APPEND qtphy_sources resources/resources.qrc @@ -17,6 +19,8 @@ list(APPEND qtphy_sources src/device_info.cpp src/rauc.hpp src/rauc.cpp + src/multimedia_formats.hpp + src/multimedia_formats.cpp ) list(APPEND qtphy_libraries @@ -26,18 +30,14 @@ list(APPEND qtphy_libraries Qt6::Gui Qt6::DBus Qt6::Multimedia + PkgConfig::gstreamer ) if(QML_SINK) - find_package(PkgConfig REQUIRED) - pkg_check_modules(gstreamer REQUIRED IMPORTED_TARGET gstreamer-1.0) list(APPEND qtphy_sources src/multimedia_qmlsink.hpp src/multimedia_qmlsink.cpp ) - list(APPEND qtphy_libraries - PkgConfig::gstreamer - ) add_definitions(-DQML_SINK) endif() diff --git a/meson.build b/meson.build index 985d943..29761a0 100644 --- a/meson.build +++ b/meson.build @@ -7,22 +7,22 @@ project( qt6 = import('qt6') qt6_dep = dependency('qt6', modules : ['Core', 'Qml', 'Quick', 'Gui', 'DBus', 'Multimedia']) -exec_dep = [qt6_dep] +exec_dep = [qt6_dep, dependency('gstreamer-1.0'), dependency('gstreamer-pbutils-1.0')] headers = [ 'src/device_info.hpp', - 'src/rauc.hpp' + 'src/rauc.hpp', + 'src/multimedia_formats.hpp' ] src = [ 'src/main.cpp', 'src/device_info.cpp', - 'src/rauc.cpp' + 'src/rauc.cpp', + 'src/multimedia_formats.cpp' ] qmlsink_option = get_option('qmlsink') if qmlsink_option.enabled() - gst_dep = dependency('gstreamer-1.0') - exec_dep += [gst_dep] add_project_arguments('-DQML_SINK', language : 'cpp') headers += ['src/multimedia_qmlsink.hpp'] src += ['src/multimedia_qmlsink.cpp'] diff --git a/qtphy.pro b/qtphy.pro index 9a67b3c..189fb4c 100644 --- a/qtphy.pro +++ b/qtphy.pro @@ -3,17 +3,21 @@ TARGET = qtphy QT += qml quick dbus +CONFIG += link_pkgconfig +PKGCONFIG += gstreamer-1.0 gstreamer-pbutils-1.0 + SOURCES += \ src/main.cpp \ src/device_info.cpp \ - src/rauc.cpp + src/rauc.cpp \ + src/multimedia_formats.cpp HEADERS += \ src/device_info.hpp \ - src/rauc.hpp + src/rauc.hpp \ + src/multimedia_formats.hpp qmlsink { - PKGCONFIG = gstreamer-1.0 SOURCES += src/multimedia_qmlsink.cpp HEADERS += src/multimedia_qmlsink.hpp } diff --git a/resources/controls/PhyConvertDialog.qml b/resources/controls/PhyConvertDialog.qml new file mode 100644 index 0000000..b4cb920 --- /dev/null +++ b/resources/controls/PhyConvertDialog.qml @@ -0,0 +1,366 @@ +import QtQuick 2.0 +import QtQuick.Controls 2.0 +import QtQuick.Layouts 1.0 +import PhyTheme 1.0 + +Rectangle { + id: convertDialog + color: PhyTheme.white + property string file + + Component { + id: codecConvertDelegate + + Item { + width: codecSelectorBox.currentText !== "REMOVE" ? ListView.view.width : 0 + height: codecSelectorBox.currentText !== "REMOVE" ? codecSelectorBox.implicitHeight: 0 + visible: codecSelectorBox.currentText !== "REMOVE" + + RowLayout { + anchors.fill: parent + spacing: PhyTheme.marginSmall + Label { + text: type + " Stream " + subIndex + ":" + Layout.alignment: Qt.AlignLeft + Layout.leftMargin: PhyTheme.marginRegular + Layout.fillWidth: true + Layout.horizontalStretchFactor: 2 + } + Label { + text: modelData + } + Label { + text: "convert to" + horizontalAlignment: Text.AlignHCenter + Layout.fillWidth: true + Layout.horizontalStretchFactor: 3 + } + ComboBox { + id: resolutionSelector + visible: type === "Video" + model: ["", "960 x 540", "1280 x 720", "1600 x 900", "1920 x 1080"] + onCurrentValueChanged: parent.updateModelValue() + Layout.alignment: Qt.AlignRight + } + ComboBox { + id: codecSelectorBox + textRole: "text" + valueRole: "value" + displayText: multimediaFormats.format(currentValue, prefix) + model: [{"text":"-","value":"-"}] + Layout.alignment: Qt.AlignRight + onCurrentValueChanged: parent.updateModelValue() + Component.onCompleted: { + loadCodecModel() + containerFormatSelectorBox.onCurrentValueChanged.connect(loadCodecModel) + containerDataSelectorBox.onCurrentValueChanged.connect(loadCodecModel) + codecSelectorView.onActiveVideoStreamsChanged.connect(loadCodecModel) + codecSelectorView.onActiveAudioStreamsChanged.connect(loadCodecModel) + } + + function loadCodecModel() { + var copyData = {"text": currentText, "value": currentValue} + var newModel = multimediaFormats.getEncodeCodecs(false, false, prefix, containerDataSelectorBox.currentValue ? + containerFormatSelectorBox.currentValue + ", " + containerDataSelectorBox.currentValue : + containerFormatSelectorBox.currentValue) + .sort(multimediaFormats.compareEncodeCodecs) + .map(codec => ({"text": multimediaFormats.formatWithDeco(codec, true, prefix), "value": codec})) + if (newModel.length === 0) + newModel = [{"text":"","value":""}] + if (currentValue) { + if (typeof multimediaGST !== "undefined") { + if (type === "Video" && codecSelectorView.activeVideoStreams > 1) + newModel.push({"text":"REMOVE","value":""}) + else if (type === "Audio" && codecSelectorView.activeAudioStreams > 1) + newModel.push({"text":"REMOVE","value":""}) + } else { + if ((codecSelectorView.activeVideoStreams + codecSelectorView.activeAudioStreams) > 1) + newModel.push({"text":"REMOVE","value":""}) + } + } else { + newModel.push({"text":"REMOVE","value":""}) + } + model = newModel + var copyIndex = 0 + if (copyData.text === "REMOVE") + copyIndex = find("REMOVE") + else if (copyData.value) + copyIndex = indexOfValue(copyData.value) + currentIndex = copyIndex > 0 ? copyIndex : 0 + } + } + + function updateModelValue() { + if (modelValue && !codecSelectorBox.currentValue) { + if (type === "Video") + codecSelectorView.activeVideoStreams -= 1 + else if (type === "Audio") + codecSelectorView.activeAudioStreams -= 1 + } else if (!modelValue && codecSelectorBox.currentValue) { + if (type === "Video") + codecSelectorView.activeVideoStreams += 1 + else if (type === "Audio") + codecSelectorView.activeAudioStreams += 1 + } + if (resolutionSelector.visible && codecSelectorBox.currentValue && resolutionSelector.currentText) { + var resolution = resolutionSelector.currentText.split("x") + modelValue = codecSelectorBox.currentValue + ",width=" + resolution[0].trim() + ",height=" + resolution[1].trim() + } else { + modelValue = codecSelectorBox.currentValue + } + } + } + } + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + RowLayout { + Layout.fillWidth: true + spacing: PhyTheme.marginRegular + Layout.margins: PhyTheme.marginSmall + + Button { + text: "Cancel" + flat: true + onClicked: convertDialogLoader.active = false + } + Label { + Layout.fillWidth: true + text: file.replace("file://", "") + elide: Text.ElideLeft + Layout.leftMargin: PhyTheme.marginRegular + Layout.rightMargin: PhyTheme.marginRegular + } + Button { + text: "Convert" + flat: true + onClicked: { + var destinationFile = (fileNameField.text ?? fileNameField.placeholderText) + extensionSelectorBox.currentValue + var containerFormat = containerDataSelectorBox.currentValue ? + containerFormatSelectorBox.currentValue + ", " + containerDataSelectorBox.currentValue : + containerFormatSelectorBox.currentValue + var selectedVideoCodecs = [] + var selectedAudioCodecs = [] + for (var i = 0; i < codecSelectorView.count; i++) { + if (fileCodecsModel.get(i).type === "Video") { + selectedVideoCodecs.push(fileCodecsModel.get(i).modelValue) + } else if (fileCodecsModel.get(i).type === "Audio") { + selectedAudioCodecs.push(fileCodecsModel.get(i).modelValue) + } + } + multimediaFormats.convertFile(file, destinationFile, containerFormat, selectedVideoCodecs, selectedAudioCodecs) + } + } + } + RowLayout { + Layout.fillWidth: true + spacing: PhyTheme.marginSmall + Label { + text: "Destination:" + Layout.leftMargin: 2 * PhyTheme.marginRegular + Layout.fillWidth: true + } + TextField { + id: fileNameField + Layout.fillWidth: true + text: file.replace(new RegExp(".+\\/([^\\/]+)\\.[^.]+$"), "$1") + "_converted" + placeholderText: "File Name" + } + ComboBox { + id: extensionSelectorBox + model: multimediaFormats.getExtensions(containerFormatSelectorBox.currentValue) + .map(extension => "." + extension) + Layout.rightMargin: PhyTheme.marginRegular + Layout.alignment: Qt.AlignRight + } + } + RowLayout { + Layout.fillWidth: true + spacing: PhyTheme.marginSmall + Label { + text: "Container Format:" + Layout.leftMargin: 2 * PhyTheme.marginRegular + Layout.rightMargin: PhyTheme.marginRegular + } + ComboBox { + id: containerFormatSelectorBox + textRole: "text" + valueRole: "value" + model: multimediaFormats.getContainerFormats() + .filter(format => format.startsWith("video/")) + .filter(format => + typeof multimediaGST === "undefined" || + (multimediaFormats.getEncodeCodecs(false, false, "video/", format).length > 0 && + multimediaFormats.getEncodeCodecs(false, false, "audio/", format).length > 0)) + .map(format => format.includes(", ") ? format.slice(0, format.indexOf(", ")) : format) + .filter((format, index, formats) => index === formats.indexOf(format) && multimediaFormats.getExtensions(format).length > 0) + .map(format => ({"text": multimediaFormats.format(format), "value": format})) + Layout.rightMargin: PhyTheme.marginRegular + } + Label { + text: "Data:" + Layout.rightMargin: PhyTheme.marginRegular + } + ComboBox { + id: containerDataSelectorBox + model: multimediaFormats.getContainerFormats() + .filter(format => format === containerFormatSelectorBox.currentValue || format.startsWith(containerFormatSelectorBox.currentValue + ", ")) + .filter(format => + typeof multimediaGST === "undefined" || + (multimediaFormats.getEncodeCodecs(false, false, "video/", format).length > 0 && + multimediaFormats.getEncodeCodecs(false, false, "audio/", format).length > 0)) + .map(format => format.includes(", ") ? format.slice(format.indexOf(", ") + 2) : "") + delegate: ItemDelegate { + required property string modelData + + text: modelData + width: containerDataSelectorBox.width + contentItem: Text { + text: parent.text + font: containerDataSelectorBox.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideNone + wrapMode: Text.WordWrap + } + } + Layout.rightMargin: PhyTheme.marginRegular + Layout.fillWidth: true + } + } + ListView { + id: codecSelectorView + property int activeVideoStreams + property int activeAudioStreams + clip: true + boundsBehavior: Flickable.StopAtBounds + model: updateFileCodecModel(file) + delegate: codecConvertDelegate + Layout.fillWidth: true + Layout.fillHeight: true + Layout.margins: PhyTheme.marginRegular + ListModel { + id: fileCodecsModel + } + function updateFileCodecModel(currentFile) { + fileCodecsModel.clear() + var codecs = multimediaFormats.getFileVideoCodec(currentFile) + activeVideoStreams = codecs.length + for (var i = 0; i < codecs.length; i++) { + fileCodecsModel.append({ + "type": "Video", + "prefix": "video/", + "subIndex": i, + "modelData": multimediaFormats.format(codecs[i], "video/"), + "modelValue": "-" + }); + } + codecs = multimediaFormats.getFileAudioCodec(currentFile) + activeAudioStreams = codecs.length + for (var i = 0; i < codecs.length; i++) { + fileCodecsModel.append({ + "type": "Audio", + "prefix": "audio/", + "subIndex": i, + "modelData": multimediaFormats.format(codecs[i], "audio/"), + "modelValue": "-" + }); + } + return fileCodecsModel + } + } + } + + Rectangle { + id: convertProgress + anchors.centerIn: parent + visible: multimediaFormats.converting + width: parent.width / 2 + height: parent.height / 2 + color: PhyTheme.white + + ColumnLayout { + anchors.fill: parent + anchors.margins: PhyTheme.marginSmall + Label { + text: "Converting..." + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + } + Label { + id: progressLabel + text: formatTime(progressBar.value) + " / " + formatTime(progressBar.to) + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + + function formatTime(nanoseconds) { + nanoseconds /= 1000000 + if (nanoseconds >= 3600000) { + return new Date(nanoseconds).toLocaleTimeString(Qt.locale(), "hh:" + "mm:" + "ss:" + "zzz") + } else { + return new Date(nanoseconds).toLocaleTimeString(Qt.locale(), "mm:" + "ss:" + "zzz") + } + } + } + ProgressBar { + id: progressBar + indeterminate: progressBar.value < 0 || progressBar.to < 0 + Layout.fillWidth: true + } + } + + Timer { + interval: 100 + repeat: true + running: convertProgress.visible + triggeredOnStart: true + onTriggered: { + progressBar.value = multimediaFormats.getConvertPosition() + progressBar.to = multimediaFormats.getConvertDuration() + } + } + } + + Rectangle { + id: convertError + anchors.centerIn: parent + visible: false + width: parent.width / 2 + height: parent.height / 2 + color: PhyTheme.white + + Component.onCompleted: multimediaFormats.conversionError.connect(onError) + Component.onDestruction: multimediaFormats.conversionError.disconnect(onError) + + function onError(message) { + convertError.visible = true; + errorLabel.text = message; + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: PhyTheme.marginSmall + Label { + text: "An error occurred:" + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + } + Label { + id: errorLabel + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + } + Button { + id: errorButton + text: "Ok" + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + onClicked: { + convertError.visible = false + errorLabel.text = "" + } + } + } + } +} diff --git a/resources/controls/PhyFileDialog.qml b/resources/controls/PhyFileDialog.qml index 0cd59ef..d0a2380 100644 --- a/resources/controls/PhyFileDialog.qml +++ b/resources/controls/PhyFileDialog.qml @@ -30,9 +30,24 @@ Rectangle { id: fileDelegate Item { + id: fileItem + property int discoverResult width: listView.width height: labelFileName.implicitHeight + Component.onCompleted: { + if (folderListModel.isFolder(index) || !fileUrl) { + discoverResult = 0 + } else { + discoverResult = multimediaFormats.getFileDiscoverResult(fileUrl) + if (typeof multimediaGST !== "undefined" && discoverResult === 0) { + if (!multimediaFormats.getFileVideoCodec(fileUrl).length || !multimediaFormats.getFileAudioCodec(fileUrl).length) + discoverResult = 5 + } + } + enabled = discoverResult === 0 + } + RowLayout { anchors.fill: parent spacing: PhyTheme.marginSmall @@ -48,9 +63,28 @@ Rectangle { elide: Text.ElideMiddle Layout.fillWidth: true } + Label { + Component.onCompleted: { + if (folderListModel.isFolder(index) || !fileUrl) + return + if (fileItem.discoverResult === 0) + text = multimediaFormats.formatList(multimediaFormats.getFileVideoCodec(fileUrl)).join(", ") + else if (fileItem.discoverResult === 2) + text = "unknown" + else if (fileItem.discoverResult === 5) + text = "unsupported" + else + text = "failed" + } + Layout.rightMargin: PhyTheme.marginSmall + } Label { text: fileSize + " B" Layout.rightMargin: PhyTheme.marginSmall + leftPadding: labelHeaderSize.leftPadding - contentWidth + labelHeaderSize.contentWidth + onContentWidthChanged: () => { + labelHeaderSize.leftPadding = Math.max(labelHeaderSize.leftPadding, contentWidth - labelHeaderSize.contentWidth) + } } } @@ -85,6 +119,7 @@ Rectangle { } ColumnLayout { + visible: !convertDialogLoader.active anchors.fill: dialog spacing: 0 @@ -105,10 +140,21 @@ Rectangle { Layout.leftMargin: PhyTheme.marginRegular Layout.rightMargin: PhyTheme.marginRegular } + Button { + text: "Convert" + flat: true + onClicked: { + if (listView.currentIndex === -1) + return + convertDialogLoader.active = true + } + } Button { text: "Open" flat: true onClicked: { + if (listView.currentIndex === -1) + return dialog.selectedFile = dialog.currentFile dialog.visible = false } @@ -130,6 +176,11 @@ Rectangle { Layout.leftMargin: PhyTheme.marginSmall } Label { + text: "Video Codec" + Layout.rightMargin: PhyTheme.marginSmall + } + Label { + id: labelHeaderSize text: "Size" Layout.rightMargin: PhyTheme.marginSmall } @@ -141,6 +192,7 @@ Rectangle { Layout.fillHeight: true Layout.fillWidth: true clip: true + currentIndex: -1 boundsBehavior: Flickable.StopAtBounds model: folderListModel delegate: fileDelegate @@ -149,4 +201,12 @@ Rectangle { } } } + + Loader { + id: convertDialogLoader + active: false + source: "PhyConvertDialog.qml" + anchors.fill: parent + onLoaded: item.file = dialog.currentFile + } } diff --git a/resources/controls/PhyToolBar.qml b/resources/controls/PhyToolBar.qml index c806ff9..b0a7357 100644 --- a/resources/controls/PhyToolBar.qml +++ b/resources/controls/PhyToolBar.qml @@ -13,23 +13,28 @@ ToolBar { property string subTitle: "" property alias buttonBack: buttonBack property alias buttonMenu: buttonMenu + property alias infoMenu: infoMenu RowLayout { anchors.fill: parent - ToolButton { - id: buttonBack - text: PhyTheme.iconFont.arrowLeft - font.family: icons.font.family - flat: true - leftPadding: PhyTheme.marginBig - rightPadding: PhyTheme.marginBig - topPadding: PhyTheme.marginRegular - bottomPadding: PhyTheme.marginRegular + RowLayout { + id: toolBarLeft + Layout.fillWidth: false + Layout.minimumWidth: toolBarRight.width + ToolButton { + id: buttonBack + text: PhyTheme.iconFont.arrowLeft + font.family: icons.font.family + flat: true + leftPadding: PhyTheme.marginBig + rightPadding: PhyTheme.marginBig + topPadding: PhyTheme.marginRegular + bottomPadding: PhyTheme.marginRegular + } } ColumnLayout { - Layout.alignment: Qt.AlignVCenter - + Layout.alignment: Qt.AlignCenter Label { text: "" + title + "" elide: Text.ElideRight @@ -41,21 +46,38 @@ ToolBar { visible: text !== "" elide: Text.ElideLeft scale: 0.8 + horizontalAlignment: Text.AlignHCenter Layout.fillWidth: true - Layout.alignment: Qt.AlignHCenter } } - ToolButton { - id: buttonMenu + RowLayout { + id: toolBarRight Layout.fillWidth: false - text: PhyTheme.iconFont.list - font.family: icons.font.family - flat: true - visible: false - leftPadding: PhyTheme.marginBig - rightPadding: PhyTheme.marginBig - topPadding: PhyTheme.marginRegular - bottomPadding: PhyTheme.marginRegular + Layout.minimumWidth: toolBarLeft.width + ToolButton { + id: infoMenu + Layout.fillWidth: false + text: PhyTheme.iconFont.info + font.family: icons.font.family + flat: true + visible: false + leftPadding: PhyTheme.marginBig + rightPadding: PhyTheme.marginBig + topPadding: PhyTheme.marginRegular + bottomPadding: PhyTheme.marginRegular + } + ToolButton { + id: buttonMenu + Layout.fillWidth: false + text: PhyTheme.iconFont.list + font.family: icons.font.family + flat: true + visible: false + leftPadding: PhyTheme.marginBig + rightPadding: PhyTheme.marginBig + topPadding: PhyTheme.marginRegular + bottomPadding: PhyTheme.marginRegular + } } } } diff --git a/resources/pages/MultimediaInfo.qml b/resources/pages/MultimediaInfo.qml new file mode 100644 index 0000000..b2d669e --- /dev/null +++ b/resources/pages/MultimediaInfo.qml @@ -0,0 +1,95 @@ +/* + * SPDX-License-Identifier: MIT + * Copyright (c) 2021 PHYTEC Messtechnik GmbH + */ + +import QtQuick 2.0 +import QtQuick.Controls 2.0 +import QtQuick.Layouts 1.0 +import Phytec.DeviceInfo 1.0 +import PhyTheme 1.0 +import "../controls" + +Page { + id: infoPage + readonly property var videoCodecs: { + "hwDecode": multimediaFormats.getDecodeCodecs(true, false, "video/"), + "hwEncode": multimediaFormats.getEncodeCodecs(true, false, "video/"), + "swDecode": multimediaFormats.getDecodeCodecs(false, true, "video/"), + "swEncode": multimediaFormats.getEncodeCodecs(false, true, "video/"), + } + readonly property var audioCodecs: { + "hwDecode": multimediaFormats.getDecodeCodecs(true, false, "audio/"), + "hwEncode": multimediaFormats.getEncodeCodecs(true, false, "audio/"), + "swDecode": multimediaFormats.getDecodeCodecs(false, true, "audio/"), + "swEncode": multimediaFormats.getEncodeCodecs(false, true, "audio/"), + } + + header: PhyToolBar { + title: "Multimedia Information" + buttonBack.onClicked: stack.pop() + buttonMenu.visible: false + } + + Flickable { + id: scrollView + anchors.fill: parent + contentWidth: content.width + contentHeight: content.height + + ColumnLayout { + id: content + width: scrollView.width + + GridLayout { + columns: 2 + columnSpacing: PhyTheme.marginBig + rowSpacing: PhyTheme.marginSmall + Layout.margins: PhyTheme.marginRegular + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop | Qt.AlignLeft + + Row { + spacing: PhyTheme.marginRegular + Label { + text: " " + PhyTheme.iconFont.cpu + " " + font.family: icons.font.family + color: PhyTheme.white + background: Rectangle { color: PhyTheme.teal2 } + } + Label { text: "

Hardware

" } + } + Label {} + Label { text: "Video Decode Codecs"; color: PhyTheme.gray3 } + Label { text: multimediaFormats.formatList(infoPage.videoCodecs["hwDecode"]).join(", "); wrapMode: Text.WordWrap; Layout.fillWidth: true } + Label { text: "Video Encode Codecs"; color: PhyTheme.gray3 } + Label { text: multimediaFormats.formatList(infoPage.videoCodecs["hwEncode"]).join(", "); wrapMode: Text.WordWrap; Layout.fillWidth: true } + Label { text: "Audio Decode Codecs"; color: PhyTheme.gray3 } + Label { text: multimediaFormats.formatList(infoPage.audioCodecs["hwDecode"], "audio/").join(", "); wrapMode: Text.WordWrap; Layout.fillWidth: true } + Label { text: "Audio Encode Codecs"; color: PhyTheme.gray3 } + Label { text: multimediaFormats.formatList(infoPage.audioCodecs["hwEncode"], "audio/").join(", "); wrapMode: Text.WordWrap; Layout.fillWidth: true } + Row { + + Layout.topMargin: 2 * PhyTheme.marginBig + spacing: PhyTheme.marginRegular + Label { + text: " " + PhyTheme.iconFont.code + " " + font.family: icons.font.family + color: PhyTheme.white + background: Rectangle { color: PhyTheme.teal2 } + } + Label { text: "

Software

" } + } + Label {} + Label { text: "Video Decode Codecs"; color: PhyTheme.gray3 } + Label { text: multimediaFormats.formatList(infoPage.videoCodecs["swDecode"]).join(", "); wrapMode: Text.WordWrap; Layout.fillWidth: true } + Label { text: "Video Encode Codecs"; color: PhyTheme.gray3 } + Label { text: multimediaFormats.formatList(infoPage.videoCodecs["swEncode"]).join(", "); wrapMode: Text.WordWrap; Layout.fillWidth: true } + Label { text: "Audio Decode Codecs"; color: PhyTheme.gray3 } + Label { text: multimediaFormats.formatList(infoPage.audioCodecs["swDecode"], "audio/").join(", "); wrapMode: Text.WordWrap; Layout.fillWidth: true } + Label { text: "Audio Encode Codecs"; color: PhyTheme.gray3 } + Label { text: multimediaFormats.formatList(infoPage.audioCodecs["swEncode"], "audio/").join(", "); wrapMode: Text.WordWrap; Layout.fillWidth: true } + } + } + } +} diff --git a/resources/pages/multimedia.qml b/resources/pages/multimedia.qml index 01f5d26..674b28f 100644 --- a/resources/pages/multimedia.qml +++ b/resources/pages/multimedia.qml @@ -19,6 +19,15 @@ Page { video.pause() stack.pop() } + infoMenu { + text: PhyTheme.iconFont.info + font.family: icons.font.family + onClicked: { + multimediaInfo.visible = true + stack.push(multimediaInfo) + } + visible: true + } buttonMenu { text: PhyTheme.iconFont.folderOpen font.family: icons.font.family @@ -77,5 +86,22 @@ Page { id: fileDialog selectedFile: "file:///usr/share/qtphy/videos/caminandes_3_llamigos_720p_vp9.webm" nameFilters: ["*.webm", "*.mp4"] + + Component.onCompleted: { + multimediaFormats.getContainerFormats([], true) + .filter(format => format.startsWith("video/")) + .map(format => format.includes(", ") ? format.slice(0, format.indexOf(", ")) : format) + .filter((format, index, formats) => index === formats.indexOf(format) && multimediaFormats.getExtensions(format).length > 0) + .forEach(format => { + fileDialog.nameFilters = fileDialog.nameFilters.concat(multimediaFormats.getExtensions(format).map(extension => "*." + extension)) + } + ) + fileDialog.nameFilters = [...new Set(fileDialog.nameFilters)] + } + } + + MultimediaInfo { + id: multimediaInfo + visible: false } } diff --git a/resources/pages/multimedia_qmlsink.qml b/resources/pages/multimedia_qmlsink.qml index b1d2d51..a7d040e 100644 --- a/resources/pages/multimedia_qmlsink.qml +++ b/resources/pages/multimedia_qmlsink.qml @@ -21,6 +21,15 @@ Page { multimediaGST.pause() stack.pop() } + infoMenu { + text: PhyTheme.iconFont.info + font.family: icons.font.family + onClicked: { + multimediaInfo.visible = true + stack.push(multimediaInfo) + } + visible: true + } buttonMenu { text: PhyTheme.iconFont.folderOpen font.family: icons.font.family @@ -123,5 +132,22 @@ Page { nameFilters: ["*.webm", "*.mp4"] onSelectedFileChanged: multimediaGST.setupNewPipeline(fileDialog.selectedFile) + + Component.onCompleted: { + multimediaFormats.getContainerFormats([], true) + .filter(format => format.startsWith("video/")) + .map(format => format.includes(", ") ? format.slice(0, format.indexOf(", ")) : format) + .filter((format, index, formats) => index === formats.indexOf(format) && multimediaFormats.getExtensions(format).length > 0) + .forEach(format => { + fileDialog.nameFilters = fileDialog.nameFilters.concat(multimediaFormats.getExtensions(format).map(extension => "*." + extension)) + } + ) + fileDialog.nameFilters = [...new Set(fileDialog.nameFilters)] + } + } + + MultimediaInfo { + id: multimediaInfo + visible: false } } diff --git a/resources/resources.qrc b/resources/resources.qrc index d2c9457..892b9d8 100644 --- a/resources/resources.qrc +++ b/resources/resources.qrc @@ -6,6 +6,7 @@ PhyStyle/Label.qml PhyStyle/ToolBar.qml PhyStyle/ToolButton.qml + controls/PhyConvertDialog.qml controls/PhyFileDialog.qml controls/PhyToolBar.qml fonts/MaterialIcons-Regular.ttf @@ -21,5 +22,6 @@ themes/PhyTheme/PhyTheme.qml themes/PhyTheme/qmldir pages/multimedia_qmlsink.qml + pages/MultimediaInfo.qml diff --git a/resources/themes/PhyTheme/PhyTheme.qml b/resources/themes/PhyTheme/PhyTheme.qml index ac7250a..7a40a6c 100644 --- a/resources/themes/PhyTheme/PhyTheme.qml +++ b/resources/themes/PhyTheme/PhyTheme.qml @@ -56,5 +56,6 @@ QtObject { readonly property string stop: "\ue047" readonly property string skipBack: "\ue045" readonly property string skipForward: "\ue044" + readonly property string info: "\ue88e" } } diff --git a/src/main.cpp b/src/main.cpp index c0ff120..8df7774 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -12,6 +12,7 @@ #include #include "device_info.hpp" #include "rauc.hpp" +#include "multimedia_formats.hpp" #ifdef QML_SINK #include "multimedia_qmlsink.hpp" @@ -87,6 +88,9 @@ int main(int argc, char *argv[]) engine.rootContext()->setContextProperty("multimediaGST", multimediaGST); #endif + MultimediaFormats *multimediaFormats = new MultimediaFormats(&app, argc, argv); + engine.rootContext()->setContextProperty("multimediaFormats", multimediaFormats); + engine.load(QUrl(QStringLiteral("qrc:///main.qml"))); #ifdef QML_SINK diff --git a/src/multimedia_formats.cpp b/src/multimedia_formats.cpp new file mode 100644 index 0000000..68f9d73 --- /dev/null +++ b/src/multimedia_formats.cpp @@ -0,0 +1,547 @@ +#include "multimedia_formats.hpp" +#include +#include +#include +#include +#include +#include +#include + +MultimediaFormats::MultimediaFormats(QObject *parent, int argc, char *argv[]) + : QObject(parent), convData() { + gst_init (&argc, &argv); + + findCodecs(); +} + +void MultimediaFormats::findCodecs() { + //Find all encodable codecs + GList *factories = gst_element_factory_list_get_elements(GST_ELEMENT_FACTORY_TYPE_ENCODER, GST_RANK_NONE); + for (GList *l = factories; l != NULL; l = l->next) { + GstElementFactory *factory = GST_ELEMENT_FACTORY(l->data); + const gchar *name = gst_plugin_feature_get_name(factory); + guint rank = gst_plugin_feature_get_rank(GST_PLUGIN_FEATURE(factory)); + const GList *pads = gst_element_factory_get_static_pad_templates(factory); + bool hardware = g_str_has_prefix(name, "vpu") || rank > 256 + || gst_element_factory_list_is_type(factory, GST_ELEMENT_FACTORY_TYPE_HARDWARE) + || QString(gst_element_factory_get_metadata(factory, GST_ELEMENT_METADATA_KLASS)).contains(GST_ELEMENT_FACTORY_KLASS_HARDWARE); + while (pads) { + GstStaticPadTemplate *padtemplate = (GstStaticPadTemplate *) pads->data; + if (padtemplate->direction == GST_PAD_SRC) { + GstCaps *caps = gst_static_pad_template_get_caps(padtemplate); + for (guint i = 0; i < gst_caps_get_size(caps); i++) { + GstStructure *structure = gst_caps_get_structure(caps, i); + QString codecName = QString(gst_structure_get_name(structure)); + if (codecName.endsWith("/x-raw") || codecName.startsWith("unknown/")) + continue; + CodecData &codecData = codecs[codecName]; + codecData.encode = true; + if (codecData.encodeRank < rank) + codecData.encodeRank = rank; + codecData.hardwareEncode |= hardware; + } + gst_caps_unref(caps); + } + pads = pads->next; + } + } + gst_plugin_feature_list_free(factories); + //Find all decodable codecs + factories = gst_element_factory_list_get_elements(GST_ELEMENT_FACTORY_TYPE_DECODER, GST_RANK_NONE); + for (GList *l = factories; l != NULL; l = l->next) { + GstElementFactory *factory = GST_ELEMENT_FACTORY(l->data); + const gchar *name = gst_plugin_feature_get_name(factory); + guint rank = gst_plugin_feature_get_rank(GST_PLUGIN_FEATURE(factory)); + const GList *pads = gst_element_factory_get_static_pad_templates(factory); + bool hardware = g_str_has_prefix(name, "vpu") || rank > 256 + || gst_element_factory_list_is_type(factory, GST_ELEMENT_FACTORY_TYPE_HARDWARE) + || QString(gst_element_factory_get_metadata(factory, GST_ELEMENT_METADATA_KLASS)).contains(GST_ELEMENT_FACTORY_KLASS_HARDWARE); + while (pads) { + GstStaticPadTemplate *padtemplate = (GstStaticPadTemplate *) pads->data; + if (padtemplate->direction == GST_PAD_SINK) { + GstCaps *caps = gst_static_pad_template_get_caps(padtemplate); + for (guint i = 0; i < gst_caps_get_size(caps); i++) { + GstStructure *structure = gst_caps_get_structure(caps, i); + QString codecName = QString(gst_structure_get_name(structure)); + if (codecName.endsWith("/x-raw") || codecName.startsWith("unknown/")) + continue; + CodecData &codecData = codecs[codecName]; + codecData.decode = true; + if (codecData.decodeRank < rank) + codecData.decodeRank = rank; + codecData.hardwareDecode |= hardware; + } + gst_caps_unref(caps); + } + pads = pads->next; + } + } + gst_plugin_feature_list_free(factories); + //Find all container formats for encoding and their supported codecs + factories = gst_element_factory_list_get_elements(GST_ELEMENT_FACTORY_TYPE_MUXER, GST_RANK_NONE); + for (GList *l = factories; l != NULL; l = l->next) { + GstElementFactory *factory = GST_ELEMENT_FACTORY(l->data); + bool hasVideo = false; + QSet avaiableCodecs; + QSet containerFormats; + const GList *pads = gst_element_factory_get_static_pad_templates(factory); + while (pads) { + GstStaticPadTemplate *padtemplate = (GstStaticPadTemplate *) pads->data; + if (padtemplate->direction == GST_PAD_SINK) { + GstCaps *caps = gst_static_pad_template_get_caps(padtemplate); + for (guint i = 0; i < gst_caps_get_size(caps); i++) { + GstStructure *structure = gst_caps_get_structure(caps, i); + QString codecName = QString(gst_structure_get_name(structure)); + if (codecs.value(codecName).encode) { + avaiableCodecs.insert(codecName); + if (codecName.startsWith("video/")) { + hasVideo = true; + } + } + } + gst_caps_unref(caps); + } else if (padtemplate->direction == GST_PAD_SRC) { + GstCaps *caps = gst_static_pad_template_get_caps(padtemplate); + for (guint i = 0; i < gst_caps_get_size(caps); i++) { + GstStructure *structure = gst_caps_get_structure(caps, i); + gchar *structureStr = gst_structure_to_string(structure); + containerFormats.insert(QString(structureStr).section(';', 0, 0)); + g_free(structureStr); + } + gst_caps_unref(caps); + } + pads = pads->next; + } + if (!avaiableCodecs.empty() && hasVideo) { + for (const auto& format : containerFormats) { + containers[format].encodeCodecs = avaiableCodecs; + } + } + } + gst_plugin_feature_list_free(factories); + //Find all container formats for decoding and their supported codecs + factories = gst_element_factory_list_get_elements(GST_ELEMENT_FACTORY_TYPE_DEMUXER, GST_RANK_NONE); + for (GList *l = factories; l != NULL; l = l->next) { + GstElementFactory *factory = GST_ELEMENT_FACTORY(l->data); + bool hasVideo = false; + QSet avaiableCodecs; + QSet containerFormats; + const GList *pads = gst_element_factory_get_static_pad_templates(factory); + while (pads) { + GstStaticPadTemplate *padtemplate = (GstStaticPadTemplate *) pads->data; + if (padtemplate->direction == GST_PAD_SRC) { + GstCaps *caps = gst_static_pad_template_get_caps(padtemplate); + for (guint i = 0; i < gst_caps_get_size(caps); i++) { + GstStructure *structure = gst_caps_get_structure(caps, i); + QString codecName = QString(gst_structure_get_name(structure)); + if (codecs.value(codecName).encode) { + avaiableCodecs.insert(codecName); + if (codecName.startsWith("video/")) { + hasVideo = true; + } + } + } + gst_caps_unref(caps); + } else if (padtemplate->direction == GST_PAD_SINK) { + GstCaps *caps = gst_static_pad_template_get_caps(padtemplate); + for (guint i = 0; i < gst_caps_get_size(caps); i++) { + GstStructure *structure = gst_caps_get_structure(caps, i); + gchar *structureStr = gst_structure_to_string(structure); + containerFormats.insert(QString(structureStr).section(';', 0, 0)); + g_free(structureStr); + } + gst_caps_unref(caps); + } + pads = pads->next; + } + if (!avaiableCodecs.empty() && hasVideo) { + for (const auto& format : containerFormats) { + containers[format].decodeCodecs = avaiableCodecs; + } + } + } + gst_plugin_feature_list_free(factories); +} + +void MultimediaFormats::cleanConvPipeline() { + if (!convData.pipeline) return; + gst_element_set_state(convData.pipeline, GST_STATE_NULL); + gst_object_unref(convData.pipeline); + convData.pipeline = NULL; + convData.source = NULL; + convData.decoder = NULL; + convData.encoder = NULL; + convData.sink = NULL; + emit convertingChanged(); +} + +MultimediaFormats::~MultimediaFormats() { + cleanConvPipeline(); + gst_deinit(); +} + +void MultimediaFormats::convertFile(QString sourceUri, QString destinationUri, QString containerFormat, QStringList videoCodecs, QStringList audioCodecs) { + cleanConvPipeline(); + convData.pipeline = gst_pipeline_new("convert-pipeline"); + convData.source = gst_element_factory_make("filesrc", "source"); + convData.decoder = gst_element_factory_make("decodebin", "decoder"); + convData.encoder = gst_element_factory_make("encodebin", "encoder"); + convData.sink = gst_element_factory_make("filesink", "sink"); + convData.videoCodecs = videoCodecs; + convData.audioCodecs = audioCodecs; + + if (!convData.pipeline || !convData.source || !convData.decoder || !convData.encoder || !convData.sink) { + qCritical() << "Not all elements for conversion pipeline could be created"; + emit conversionError("Not all elements for conversion pipeline could be created"); + cleanConvPipeline(); + return; + } + + // Select the format of the container (e.g. mpeg/matroska) from the parameter + GstCaps *caps = gst_caps_from_string(containerFormat.toStdString().c_str()); + GstEncodingContainerProfile *containerProfile = gst_encoding_container_profile_new("container", NULL, caps, NULL); + gst_caps_unref(caps); + + // Select video codecs for encoding from parameter + for (const auto &codec : videoCodecs) { + if (codec.isEmpty()) + continue; + caps = gst_caps_from_string(codec.toStdString().c_str()); + GstEncodingVideoProfile *videoProfile = gst_encoding_video_profile_new(caps, NULL, NULL, 1); + gst_encoding_container_profile_add_profile(containerProfile, (GstEncodingProfile *) videoProfile); + gst_caps_unref(caps); + } + + // Select audio codecs for encoding from parameter + for (const auto &codec : audioCodecs) { + if (codec.isEmpty()) + continue; + caps = gst_caps_from_string(codec.toStdString().c_str()); + GstEncodingAudioProfile *audioProfile = gst_encoding_audio_profile_new(caps, NULL, NULL, 1); + gst_encoding_container_profile_add_profile(containerProfile, (GstEncodingProfile *) audioProfile); + gst_caps_unref(caps); + } + + g_object_set(convData.encoder, "profile", containerProfile, NULL); + g_object_set(convData.source, "location", sourceUri.replace("file://", "").toStdString().c_str(), NULL); + g_object_set(convData.sink, "location", destinationUri.replace("file://", "").toStdString().c_str(), NULL); + + // Build the pipeline + gst_bin_add_many(GST_BIN(convData.pipeline), convData.source, convData.decoder, convData.encoder, convData.sink, NULL); + // Link source to decoder, encoder to sink + if (!gst_element_link(convData.source, convData.decoder) || !gst_element_link(convData.encoder, convData.sink)) { + qCritical() << "Could not link all elements in conversion pipeline"; + emit conversionError("Could not link all elements in conversion pipeline"); + cleanConvPipeline(); + return; + } + // Dynamically link decoder and encoder + g_signal_connect(convData.decoder, "pad-added", G_CALLBACK((+[](GstElement *src, GstPad *pad, MultimediaFormats *multimediaFormats) { + GstCaps* caps = gst_pad_get_current_caps(pad); + GstStructure* structure = gst_caps_get_structure(caps, 0); + const gchar *name = gst_structure_get_name(structure); + gst_caps_unref(caps); + + if ((g_str_has_prefix(name, "video") && multimediaFormats->convData.videoCodecs.takeLast().isEmpty()) + || (g_str_has_prefix(name, "audio") && multimediaFormats->convData.audioCodecs.takeLast().isEmpty())) { + return; + } + GstPad *sinkPad = gst_element_get_compatible_pad(multimediaFormats->convData.encoder, pad, NULL); + + if (sinkPad) { + gst_pad_link(pad, sinkPad); + gst_object_unref(sinkPad); + } else { + qCritical() << "Failed to get matching pad for pad with name:" << name; + emit multimediaFormats->conversionError("Could not dynamically link all encoder pads to decoder"); + g_idle_add(G_SOURCE_FUNC(+[](MultimediaFormats *multimediaFormats) { + multimediaFormats->cleanConvPipeline(); + return G_SOURCE_REMOVE; + }), multimediaFormats); + } + })), this); + + // Run the pipeline + GstStateChangeReturn ret = gst_element_set_state(convData.pipeline, GST_STATE_PLAYING); + if (ret == GST_STATE_CHANGE_FAILURE) { + qCritical() << "Conversion pipeline doesn't want to pause"; + emit conversionError("Conversion pipeline doesn't want to pause"); + cleanConvPipeline(); + return; + } + + // Listen for EOS or error from the pipeline + GstBus *bus = gst_element_get_bus(convData.pipeline); + gst_bus_add_watch(bus, +[](GstBus* bus, GstMessage* message, gpointer data) -> gboolean { + auto *multimediaFormats = static_cast(data); + if (GST_MESSAGE_TYPE(message) == GST_MESSAGE_EOS) { + multimediaFormats->cleanConvPipeline(); + return G_SOURCE_REMOVE; + } else if (GST_MESSAGE_TYPE(message) == GST_MESSAGE_ERROR) { + GError* error; + gchar* debug; + gst_message_parse_error(message, &error, &debug); + qCritical() << "Conversion pipeline exited with error:" << error->message; + emit multimediaFormats->conversionError(QString("Conversion pipeline exited with error: ") + error->message); + g_error_free(error); + g_free(debug); + multimediaFormats->cleanConvPipeline(); + return G_SOURCE_REMOVE; + } + return G_SOURCE_CONTINUE; + }, this); + gst_object_unref(bus); + + emit convertingChanged(); +} + +bool MultimediaFormats::isConverting() { + return convData.pipeline; +} + +gint64 MultimediaFormats::getConvertDuration() { + gint64 duration; + if (convData.pipeline && gst_element_query_duration(convData.pipeline, GST_FORMAT_TIME, &duration)) + return duration; + return -1; +} + +gint64 MultimediaFormats::getConvertPosition() { + gint64 position; + if (convData.pipeline && gst_element_query_position(convData.pipeline, GST_FORMAT_TIME, &position)) + return position; + return -1; +} + +bool MultimediaFormats::isHardwareCodec(QString codec, bool encode) { + if (encode) { + return codecs.value(codec).hardwareEncode; + } else { + return codecs.value(codec).hardwareDecode; + } +} + +QString MultimediaFormats::format(QString codec, QString prefix) { + return codec.replace(QRegularExpression("^" + prefix + "(x-)?"), ""); +} + +QString MultimediaFormats::formatWithDeco(QString codec, bool encode, QString prefix) { + if (isHardwareCodec(codec, encode)) { + return format(codec, prefix); + } else { + return "" + format(codec, prefix) + ""; + } +} + +QStringList MultimediaFormats::formatList(QStringList codecs, QString prefix) { + return codecs.replaceInStrings(QRegularExpression("^" + prefix + "(x-)?"), ""); +} + +int MultimediaFormats::compareEncodeCodecs(QString codec1, QString codec2) { + int hardware = (2 * isHardwareCodec(codec2, true) + isHardwareCodec(codec2, false)) - (2 * isHardwareCodec(codec1, true) + isHardwareCodec(codec1, false)); + if (hardware != 0) + return hardware; + return getCodecRank(codec2, true) - getCodecRank(codec1, true); +} + +guint MultimediaFormats::getCodecRank(QString codec, bool encode) { + if (encode) { + return codecs.value(codec).encodeRank; + } else { + return codecs.value(codec).decodeRank; + } +} + +int MultimediaFormats::getFileDiscoverResult(QString uri) { + GError *err = NULL; + GstDiscoverer *discoverer = gst_discoverer_new(GST_SECOND, &err); + GstDiscovererInfo *info = gst_discoverer_discover_uri(discoverer, uri.toStdString().c_str(), &err); + int result = gst_discoverer_info_get_result(info); + if (err) { + g_error_free(err); + } + if (info) { + gst_discoverer_info_unref(info); + } + g_object_unref(discoverer); + return result; +} + +QString MultimediaFormats::getFileFormat(QString uri) { + GError *err = NULL; + GstDiscoverer *discoverer = gst_discoverer_new(GST_SECOND, &err); + GstDiscovererInfo *info = gst_discoverer_discover_uri(discoverer, uri.toStdString().c_str(), &err); + QString format; + if (info) { + GstDiscovererStreamInfo* streamInfo = gst_discoverer_info_get_stream_info(info); + if (streamInfo) { + GstCaps* caps = gst_discoverer_stream_info_get_caps(streamInfo); + if (caps) { + gchar* format_name = gst_caps_to_string(caps); + format = QString(format_name); + g_free(format_name); + gst_caps_unref(caps); + } + } + gst_discoverer_info_unref(info); + } + if (err) { + g_error_free(err); + } + g_object_unref(discoverer); + return format; +} + +QStringList MultimediaFormats::getFileVideoCodec(QString uri) { + GError *err = NULL; + GstDiscoverer *discoverer = gst_discoverer_new(GST_SECOND, &err); + GstDiscovererInfo *info = gst_discoverer_discover_uri(discoverer, uri.toStdString().c_str(), &err); + QStringList codecs; + if (info) { + GList *videoStreams = gst_discoverer_info_get_video_streams(info); + for (GList *l = videoStreams; l != NULL; l = l->next) { + GstDiscovererStreamInfo *streamInfo = (GstDiscovererStreamInfo *)l->data; + GstCaps *caps = gst_discoverer_stream_info_get_caps(streamInfo); + if (!caps) + continue; + for (guint i = 0; i < gst_caps_get_size(caps); i++) { + GstStructure *structure = gst_caps_get_structure(caps, i); + codecs.append(QString(gst_structure_get_name(structure))); + } + gst_caps_unref(caps); + } + gst_discoverer_stream_info_list_free(videoStreams); + gst_discoverer_info_unref(info); + } + if (err) { + g_error_free(err); + } + g_object_unref(discoverer); + return codecs; +} + +QStringList MultimediaFormats::getFileAudioCodec(QString uri) { + GError *err = NULL; + GstDiscoverer *discoverer = gst_discoverer_new(GST_SECOND, &err); + GstDiscovererInfo *info = gst_discoverer_discover_uri(discoverer, uri.toStdString().c_str(), &err); + QStringList codecs; + if (info) { + GList *audioStreams = gst_discoverer_info_get_audio_streams(info); + for (GList *l = audioStreams; l != NULL; l = l->next) { + GstDiscovererStreamInfo *streamInfo = (GstDiscovererStreamInfo *)l->data; + GstCaps *caps = gst_discoverer_stream_info_get_caps(streamInfo); + if (!caps) + continue; + for (guint i = 0; i < gst_caps_get_size(caps); i++) { + GstStructure *structure = gst_caps_get_structure(caps, i); + codecs.append(QString(gst_structure_get_name(structure))); + } + gst_caps_unref(caps); + } + gst_discoverer_stream_info_list_free(audioStreams); + gst_discoverer_info_unref(info); + } + if (err) { + g_error_free(err); + } + g_object_unref(discoverer); + return codecs; +} + +QStringList MultimediaFormats::getEncodeCodecs(bool onlyHardware, bool onlySoftware, QString prefix, QString containerFormat) { + QStringList selectedCodecs; + if (containerFormat.isNull()) { + for (auto [codec, data] : codecs.asKeyValueRange()) { + if (data.encode && !(onlyHardware && !data.hardwareEncode) + && !(onlySoftware && data.hardwareEncode) && codec.startsWith(prefix)) { + selectedCodecs.append(codec); + } + } + } else { + for (const auto &codec : containers.value(containerFormat).encodeCodecs) { + if (!codecs.contains(codec)) + continue; + CodecData data = codecs.value(codec); + if (data.encode && !(onlyHardware && !data.hardwareEncode) + && !(onlySoftware && data.hardwareEncode) && codec.startsWith(prefix)) { + selectedCodecs.append(codec); + } + } + } + return selectedCodecs; +} + +QStringList MultimediaFormats::getDecodeCodecs(bool onlyHardware, bool onlySoftware, QString prefix, QString containerFormat) { + QStringList selectedCodecs; + if (containerFormat.isNull()) { + for (auto [codec, data] : codecs.asKeyValueRange()) { + if (data.decode && !(onlyHardware && !data.hardwareDecode) + && !(onlySoftware && data.hardwareDecode) && codec.startsWith(prefix)) { + selectedCodecs.append(codec); + } + } + } else { + for (const auto &codec : containers.value(containerFormat).decodeCodecs) { + if (!codecs.contains(codec)) + continue; + CodecData data = codecs.value(codec); + if (data.decode && !(onlyHardware && !data.hardwareDecode) + && !(onlySoftware && data.hardwareDecode) && codec.startsWith(prefix)) { + selectedCodecs.append(codec); + } + } + } + return selectedCodecs; +} + +QStringList MultimediaFormats::getContainerFormats(QStringList codecs, bool all, bool encode) { + QStringList containerFormats; + if (all) { + for (auto [key, value] : containers.asKeyValueRange()) { + containerFormats.append(key); + for (const auto &codec : codecs) { + if (!value.encodeCodecs.contains(codec) && !value.decodeCodecs.contains(codec)) { + containerFormats.removeLast(); + break; + } + } + } + } else { + for (auto [key, value] : containers.asKeyValueRange()) { + if ((encode && value.encodeCodecs.isEmpty()) + || (!encode && value.decodeCodecs.isEmpty())) + continue; + containerFormats.append(key); + for (const auto &codec : codecs) { + if ((encode && !value.encodeCodecs.contains(codec)) + || (!encode && !value.decodeCodecs.contains(codec))) { + containerFormats.removeLast(); + break; + } + } + } + } + return containerFormats; +} + +QStringList MultimediaFormats::getExtensions(QString containerFormat) { + QSet extensionList; + GstCaps* caps = gst_caps_from_string(containerFormat.toStdString().c_str()); + GList *factories = gst_type_find_factory_get_list(); + for (GList* l = factories; l != NULL; l = l->next) { + GstTypeFindFactory* factory = GST_TYPE_FIND_FACTORY(l->data); + GstCaps* f_caps = gst_type_find_factory_get_caps(factory); + + if (f_caps && gst_caps_can_intersect(caps, f_caps)) { + const gchar* const* extensions = gst_type_find_factory_get_extensions(factory); + if (extensions) { + for (int j = 0; extensions[j] != NULL; j++) { + extensionList.insert(extensions[j]); + } + } + } + } + gst_caps_unref(caps); + gst_plugin_feature_list_free(factories); + return extensionList.values(); +} diff --git a/src/multimedia_formats.hpp b/src/multimedia_formats.hpp new file mode 100644 index 0000000..91dd446 --- /dev/null +++ b/src/multimedia_formats.hpp @@ -0,0 +1,77 @@ +#ifndef MULTIMEDIA_FORMATS_HPP +#define MULTIMEDIA_FORMATS_HPP + +#include +#include +#include +#include +#include +#include + +struct ConversionData { + GstElement *pipeline; + GstElement *source; + GstElement *decoder; + GstElement *encoder; + GstElement *sink; + QStringList videoCodecs; + QStringList audioCodecs; +}; + +struct CodecData { + guint encodeRank; + guint decodeRank; + bool hardwareEncode; + bool hardwareDecode; + bool encode; + bool decode; +}; + +struct ContainerData { + QSet encodeCodecs; + QSet decodeCodecs; +}; + +class MultimediaFormats : public QObject { + Q_OBJECT + Q_PROPERTY(bool converting READ isConverting NOTIFY convertingChanged) + +private: + ConversionData convData; + QMap codecs; + QMap containers; + void findCodecs(); + void cleanConvPipeline(); + +public: + explicit MultimediaFormats(QObject *parent = nullptr, int argc = 0, char *argv[] = nullptr); + ~MultimediaFormats(); + bool isConverting(); + +public slots: + void convertFile(QString sourceUri, QString destinationUri, QString containerFormat, QStringList videoCodecs, QStringList audioCodecs); + gint64 getConvertDuration(); + gint64 getConvertPosition(); + bool isHardwareCodec(QString codec, bool encode); + QString format(QString codec, QString prefix = "video/"); + QString formatWithDeco(QString codec, bool encode, QString prefix = "video/"); + QStringList formatList(QStringList codecs, QString prefix = "video/"); + int compareEncodeCodecs(QString codec1, QString codec2); + guint getCodecRank(QString codec, bool encode); + int getFileDiscoverResult(QString uri); + QString getFileFormat(QString uri); + QStringList getFileVideoCodec(QString uri); + QStringList getFileAudioCodec(QString uri); + QStringList getEncodeCodecs(bool onlyHardware = false, bool onlySoftware = false, + QString prefix = QString(), QString containerFormat = QString()); + QStringList getDecodeCodecs(bool onlyHardware = false, bool onlySoftware = false, + QString prefix = QString(), QString containerFormat = QString()); + QStringList getContainerFormats(QStringList codecs = QStringList(), bool all = false, bool encode = true); + QStringList getExtensions(QString containerFormat); + +signals: + void convertingChanged(); + void conversionError(QString message); +}; + +#endif // MULTIMEDIA_FORMATS_HPP From 653e90ccb02e0e054f5c358849cc6161f3cab614 Mon Sep 17 00:00:00 2001 From: Tobias Hahn Date: Mon, 9 Mar 2026 13:20:32 +0100 Subject: [PATCH 3/3] workflows: Add gstreamer build dependency Install gstreamer dependency to allow the building with the information on video codecs and conversion of video files. --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cbb2728..5d71973 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,7 +15,7 @@ jobs: - name: Install build dependencies run: | sudo apt-get update - sudo apt-get install meson cmake ninja-build qt6-base-dev qt6-declarative-dev qt6-multimedia-dev + sudo apt-get install meson cmake ninja-build qt6-base-dev qt6-declarative-dev qt6-multimedia-dev libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev - name: Build application using Meson Build System run: | meson setup build-meson --buildtype=release