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 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 38f9e9c..7a40a6c 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" @@ -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