Revision control

Copy as Markdown

// Copyright (C) 2021 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
#include "playbackengine/qffmpegmediadataholder_p.h"
#include "qffmpegmediametadata_p.h"
#include "qffmpegmediaformatinfo_p.h"
#include "qffmpegioutils_p.h"
#include <QtMultimedia/qplaybackoptions.h>
#include <QtMultimedia/private/qmediametadata_p.h>
#include <QtCore/qiodevice.h>
#include <QtCore/qdatetime.h>
#include <QtCore/qloggingcategory.h>
#include <QtCore/private/qminimalflatset_p.h>
#if defined(Q_OS_ANDROID)
# include <QtFFmpegMediaPluginImpl/private/qandroidcacertificates_p.h>
#endif
#include <optional>
QT_BEGIN_NAMESPACE
Q_STATIC_LOGGING_CATEGORY(qLcMediaDataHolder, "qt.multimedia.ffmpeg.mediadataholder")
namespace QFFmpeg {
using TrackType = QPlatformMediaPlayer::TrackType;
static std::optional<TrackDuration> streamDuration(const AVStream &stream)
{
if (stream.duration > 0)
return toTrackDuration(AVStreamDuration(stream.duration), &stream);
// In some cases ffmpeg reports negative duration that is definitely invalid.
// However, the correct duration may be read from the metadata.
if (stream.duration < 0 && stream.duration != AV_NOPTS_VALUE) {
qCWarning(qLcMediaDataHolder) << "AVStream duration" << stream.duration
<< "is invalid. Taking it from the metadata";
}
if (const auto duration = av_dict_get(stream.metadata, "DURATION", nullptr, 0)) {
const auto time = QTime::fromString(QString::fromUtf8(duration->value));
return TrackDuration(qint64(1000) * time.msecsSinceStartOfDay());
}
return {};
}
static std::optional<TrackPosition> streamStart(const AVStream &stream,
const AVDemuxerContextUPtr &context)
{
if (stream.start_time != AV_NOPTS_VALUE)
return toTrackPosition(AVStreamPosition(stream.start_time), &stream, context.get());
else
return {};
}
static QTransform displayMatrixToTransform(const int32_t *displayMatrix)
{
// displayMatrix is stored as
//
// . -- X axis
// |
// | | a b u |
// Y | c d v |
// axis | x y w |
//
// where a, b, c, d, x, y are 16.16 fixed-point values,
// and u, v, w are 30.2 point values.
// Only a, b, c, d impacts on mirroring and rotation,
// so it's enough to propagate them to QTransform.
//
// If we were interested in getting proper XY scales,
// we would divide a,b,c,d by 2^16. The whole scale doesn't
// impact mirroring and rotation, so we don't do so.
auto toRotateMirrorValue = [displayMatrix](int index) {
// toRotateScaleValue would be:
// return displayMatrix[index] / qreal(1 << 16);
return displayMatrix[index];
};
return QTransform(toRotateMirrorValue(0), toRotateMirrorValue(1),
toRotateMirrorValue(3), toRotateMirrorValue(4),
0, 0);
}
static VideoTransformation streamTransformation(const AVStream *stream)
{
Q_ASSERT(stream);
using SideDataSize = decltype(AVPacketSideData::size);
constexpr SideDataSize displayMatrixSize = sizeof(int32_t) * 9;
const AVPacketSideData *sideData = streamSideData(stream, AV_PKT_DATA_DISPLAYMATRIX);
if (!sideData || sideData->size < displayMatrixSize)
return {};
const auto displayMatrix = reinterpret_cast<const int32_t *>(sideData->data);
const QTransform transform = displayMatrixToTransform(displayMatrix);
const VideoTransformationOpt result = qVideoTransformationFromMatrix(transform);
if (!result) {
qCWarning(qLcMediaDataHolder)
<< "Video stream contains malformed display matrix" << transform;
return {};
}
return *result;
}
static bool colorTransferSupportsHdr(const AVStream *stream)
{
if (!stream)
return false;
const AVCodecParameters *codecPar = stream->codecpar;
if (!codecPar)
return false;
const QVideoFrameFormat::ColorTransfer colorTransfer = fromAvColorTransfer(codecPar->color_trc);
// Assume that content is using HDR if the color transfer supports high
// dynamic range. The video may still not utilize the extended range,
// but we can't determine the actual range without decoding frames.
return colorTransfer == QVideoFrameFormat::ColorTransfer_ST2084
|| colorTransfer == QVideoFrameFormat::ColorTransfer_STD_B67;
}
VideoTransformation MediaDataHolder::transformation() const
{
// TODO: Add QMediaMetaData::Mirrored and take from it and QMediaMetaData::Orientation:
// int orientation = m_metaData.value(QMediaMetaData::Orientation).toInt();
// return static_cast<QtVideo::Rotation>(orientation);
const int streamIndex = m_currentAVStreamIndex[TrackType::VideoStream];
if (streamIndex < 0)
return {};
return streamTransformation(m_context->streams[streamIndex]);
}
AVFormatContext *MediaDataHolder::avContext()
{
return m_context.get();
}
int MediaDataHolder::currentStreamIndex(TrackType trackType) const
{
return m_currentAVStreamIndex[trackType];
}
static void insertMediaData(QMediaMetaData &metaData, TrackType trackType, const AVStream *stream)
{
Q_ASSERT(stream);
const auto *codecPar = stream->codecpar;
switch (trackType) {
case TrackType::VideoStream:
metaData.insert(QMediaMetaData::VideoBitRate, (int)codecPar->bit_rate);
metaData.insert(QMediaMetaData::VideoCodec,
QVariant::fromValue(QFFmpegMediaFormatInfo::videoCodecForAVCodecId(
codecPar->codec_id)));
metaData.insert(QMediaMetaData::Resolution, QSize(codecPar->width, codecPar->height));
metaData.insert(QMediaMetaData::VideoFrameRate,
qreal(stream->avg_frame_rate.num) / qreal(stream->avg_frame_rate.den));
metaData.insert(QMediaMetaData::Orientation,
QVariant::fromValue(streamTransformation(stream).rotation));
metaData.insert(QMediaMetaData::HasHdrContent, colorTransferSupportsHdr(stream));
break;
case TrackType::AudioStream:
metaData.insert(QMediaMetaData::AudioBitRate, (int)codecPar->bit_rate);
metaData.insert(QMediaMetaData::AudioCodec,
QVariant::fromValue(QFFmpegMediaFormatInfo::audioCodecForAVCodecId(
codecPar->codec_id)));
break;
default:
break;
}
};
std::optional<TrackType> MediaDataHolder::trackTypeFromMediaType(int mediaType)
{
switch (mediaType) {
case AVMEDIA_TYPE_AUDIO:
return TrackType::AudioStream;
case AVMEDIA_TYPE_VIDEO:
return TrackType::VideoStream;
case AVMEDIA_TYPE_SUBTITLE:
return TrackType::SubtitleStream;
default:
return std::nullopt;
}
}
namespace {
q23::expected<AVDemuxerContextUPtr, MediaDataHolder::ContextError>
loadMedia(const QUrl &mediaUrl, QIODevice *stream, const QPlaybackOptions &playbackOptions,
const std::shared_ptr<ICancelToken> &cancelToken)
{
using std::chrono::duration_cast;
using std::chrono::microseconds;
using std::chrono::milliseconds;
const QByteArray url = mediaUrl.toString(QUrl::PreferLocalFile).toUtf8();
AVDemuxerContextUPtr context{ avformat_alloc_context() };
if (stream) {
if (!stream->isOpen()) {
if (!stream->open(QIODevice::ReadOnly))
return q23::unexpected{
MediaDataHolder::ContextError{
QMediaPlayer::ResourceError,
QLatin1String("Could not open source device."),
},
};
}
auto seek = &seekQIODevice;
if (!stream->isSequential()) {
stream->seek(0);
} else {
context->ctx_flags |= AVFMTCTX_UNSEEKABLE;
seek = nullptr;
}
constexpr int bufferSize = 32768;
unsigned char *buffer = (unsigned char *)av_malloc(bufferSize);
context->pb = avio_alloc_context(buffer, bufferSize, false, stream, &readQIODevice, nullptr,
seek);
}
AVDictionaryHolder dict;
using RtmpProtocols =
QMinimalVarLengthFlatSet<std::basic_string_view<char16_t>, 6, std::less<>>;
static const RtmpProtocols rtmpProtocols{
u"rtmp", u"rtmpe", u"rtmps", u"rtmpt", u"rtmpse", u"rtmpte",
};
// for rtmp streams, the `timout` parameter implies acting as a server:
// This is not the semantics of QPlaybackOptions::networkTimeout, and will cause failures when
// opening streams
const bool setNetworkTimeout = !rtmpProtocols.contains(mediaUrl.scheme());
if (setNetworkTimeout) {
const milliseconds timeout = playbackOptions.networkTimeout();
av_dict_set_int(dict, "timeout", duration_cast<microseconds>(timeout).count(), 0);
qCDebug(qLcMediaDataHolder) << "Using custom network timeout:" << timeout;
}
{
const int probeSize = playbackOptions.probeSize();
if (probeSize != -1) {
constexpr int minProbeSizeFFmpeg = 32;
if (probeSize >= minProbeSizeFFmpeg) {
av_dict_set_int(dict, "probesize", probeSize, 0);
qCDebug(qLcMediaDataHolder) << "Using custom probesize" << probeSize;
} else
qCWarning(qLcMediaDataHolder) << "Invalid probe size, using default";
}
}
const QByteArray protocolWhitelist = qgetenv("QT_FFMPEG_PROTOCOL_WHITELIST");
if (!protocolWhitelist.isNull())
av_dict_set(dict, "protocol_whitelist", protocolWhitelist.data(), 0);
if (mediaUrl.scheme().compare(u"rtsp") == 0) {
const QByteArray rtspTransport = qgetenv("QT_FFMPEG_RTSP_TRANSPORT").trimmed();
if (!rtspTransport.isEmpty()) {
av_dict_set(dict, "rtsp_transport", rtspTransport.constData(), 0);
qCDebug(qLcMediaDataHolder) << "Using custom RTSP transport:" << rtspTransport;
}
}
if (playbackOptions.playbackIntent() == QPlaybackOptions::PlaybackIntent::LowLatencyStreaming) {
av_dict_set(dict, "fflags", "nobuffer", 0);
av_dict_set_int(dict, "flush_packets", 1, 0);
qCDebug(qLcMediaDataHolder) << "Enabled low latency streaming";
}
// QTBUG-145590: for hls streams, we want to disable http persistent connections to allow FFmpeg
// (before FFmpeg 8?) to mix raw and encrypted streams
if (avformat_version() < AV_VERSION_INT(62, 12, 100))
av_dict_set_int(dict, "http_persistent", 0, 0);
#ifdef Q_OS_ANDROID
// QTBUG-150720: FFmpeg >= 9.0 defaults its tls protocol to peer certificate verification,
// but we do not supply a CA bundle on Android by default, so every https:// source would
// otherwise fail to open.
// TODO: Apply the app's Network Security Configuration to media sources: domain-specific
// trust anchors, Certificate Transparency and cleartextTrafficPermitted="false". The CA
// bundle only carries the base-config trust anchors, so this requires verifying the peer
// chain per host through the same trust manager QtSslCertificates reads the anchors from
// (via X509TrustManagerExtensions.checkServerTrusted(chain, authType, host)), and rejecting
// cleartext protocols based on NetworkSecurityPolicy.isCleartextTrafficPermitted(host).
{
const QString caBundlePath = androidFFmpegCaBundlePath();
if (!caBundlePath.isEmpty())
av_dict_set(dict, "ca_file", caBundlePath.toUtf8().constData(), 0);
}
#endif
// NOTE: For other platforms, CA bundles in default directories are OK,
// and one can set non-standard locations with SSL_CERT_DIR=<directory>
context->interrupt_callback.opaque = cancelToken.get();
context->interrupt_callback.callback = [](void *opaque) {
const auto *cancelToken = static_cast<const ICancelToken *>(opaque);
if (cancelToken && cancelToken->isCancelled())
return 1;
return 0;
};
int ret = 0;
AVIOContext *customIo = context->pb;
{
AVFormatContext *contextRaw = context.release();
ret = avformat_open_input(&contextRaw, url.constData(), nullptr, dict);
context.reset(contextRaw);
}
if (ret < 0) {
auto code = QMediaPlayer::ResourceError;
if (ret == AVERROR(EACCES))
code = QMediaPlayer::AccessDeniedError;
else if (ret == AVERROR(EINVAL) || ret == AVERROR_INVALIDDATA)
code = QMediaPlayer::FormatError;
qCWarning(qLcMediaDataHolder)
<< "Could not open media. FFmpeg error description:" << AVError(ret);
freeAVIOContext(customIo);
return q23::unexpected{
MediaDataHolder::ContextError{ code, QMediaPlayer::tr("Could not open file") },
};
}
ret = avformat_find_stream_info(context.get(), nullptr);
if (ret < 0) {
return q23::unexpected{
MediaDataHolder::ContextError{
QMediaPlayer::FormatError,
QMediaPlayer::tr("Could not find stream information for media file") },
};
}
if (qLcMediaDataHolder().isInfoEnabled())
av_dump_format(context.get(), 0, url.constData(), 0);
return context;
}
} // namespace
MediaDataHolder::Maybe MediaDataHolder::create(const QUrl &url, QIODevice *stream,
const QPlaybackOptions &options,
const std::shared_ptr<ICancelToken> &cancelToken)
{
q23::expected context = loadMedia(url, stream, options, cancelToken);
if (context) {
// MediaDataHolder is wrapped in a shared pointer to interop with signal/slot mechanism
return std::make_shared<MediaDataHolder>(
MediaDataHolder{ std::move(context.value()), cancelToken });
}
return q23::unexpected{ context.error() };
}
MediaDataHolder::MediaDataHolder(AVDemuxerContextUPtr context,
const std::shared_ptr<ICancelToken> &cancelToken)
: m_cancelToken{ cancelToken }
{
Q_ASSERT(context);
m_context = std::move(context);
m_isSeekable = !(m_context->ctx_flags & AVFMTCTX_UNSEEKABLE);
std::optional<TrackDuration> mediaDuration;
for (unsigned int i = 0; i < m_context->nb_streams; ++i) {
const auto *stream = m_context->streams[i];
const auto trackType = trackTypeFromMediaType(stream->codecpar->codec_type);
if (!trackType)
continue;
if (stream->disposition & AV_DISPOSITION_ATTACHED_PIC)
continue; // Ignore attached picture streams because we treat them as metadata
if (stream->time_base.num <= 0 || stream->time_base.den <= 0) {
// An invalid stream timebase is not expected to be given by FFmpeg
qCWarning(qLcMediaDataHolder) << "A stream for the track type" << trackType
<< "has an invalid timebase:" << stream->time_base;
continue;
}
auto metaData = QFFmpegMetaData::fromAVMetaData(stream->metadata);
const bool isDefault = stream->disposition & AV_DISPOSITION_DEFAULT;
if (trackType != TrackType::SubtitleStream) {
insertMediaData(metaData, *trackType, stream);
auto &requestedStream = m_requestedStreams[*trackType];
if (isDefault && requestedStream < 0)
requestedStream = m_streamMap[*trackType].size();
}
if (auto duration = streamDuration(*stream))
metaData.insert(QMediaMetaData::Duration, toUserDuration(*duration).get());
m_streamMap[*trackType].append({ (int)i, isDefault, metaData });
}
// With some media files, streams may be lacking duration info. Let's
// get it from ffmpeg's duration estimation instead.
if (m_context->duration > 0ll)
mediaDuration = toTrackDuration(AVContextDuration(m_context->duration));
if (!mediaDuration) {
std::optional<AVContextDuration> contextStart = contextStartOffset(m_context.get());
std::optional<TrackPosition> startPosition = [&]() -> std::optional<TrackPosition> {
if (contextStart)
return toTrackDuration(*contextStart).asTimePoint();
return std::nullopt;
}();
std::optional<TrackPosition> endPosition;
QSpan streams{ m_context->streams, qsizetype(m_context->nb_streams) };
for (const AVStream *stream : streams) {
std::optional<TrackPosition> currentStreamStartPosition =
streamStart(*stream, m_context);
std::optional<TrackDuration> currentStreamDuration = streamDuration(*stream);
std::optional<TrackPosition> currentStreamEndPosition =
[&]() -> std::optional<TrackPosition> {
if (currentStreamStartPosition && currentStreamDuration)
return *currentStreamStartPosition + *currentStreamDuration;
return std::nullopt;
}();
if (startPosition)
startPosition = std::min(*startPosition,
currentStreamStartPosition.value_or(*startPosition));
else
startPosition = currentStreamStartPosition;
endPosition = std::max(endPosition, currentStreamEndPosition);
}
if (endPosition && startPosition)
mediaDuration = *endPosition - *startPosition;
}
m_duration = mediaDuration.value_or(TrackDuration::zero());
for (auto trackType : { TrackType::VideoStream,
TrackType::AudioStream }) {
auto &requestedStream = m_requestedStreams[trackType];
auto &streamMap = m_streamMap[trackType];
if (requestedStream < 0 && !streamMap.empty())
requestedStream = 0;
if (requestedStream >= 0)
m_currentAVStreamIndex[trackType] =
streamMap[requestedStream].avStreamIndex;
}
updateMetaData();
}
namespace {
/*!
\internal
Attempt to find an attached picture from the context's streams.
This will find ID3v2 pictures on audio files, and also pictures
attached to videos.
*/
QImage getAttachedPicture(const AVFormatContext *context)
{
if (!context)
return {};
for (unsigned int i = 0; i < context->nb_streams; ++i) {
const AVStream* stream = context->streams[i];
if (!stream || !(stream->disposition & AV_DISPOSITION_ATTACHED_PIC))
continue;
const AVPacket *compressedImage = &stream->attached_pic;
if (!compressedImage || !compressedImage->data || compressedImage->size <= 0)
continue;
// Feed raw compressed data to QImage::fromData, which will decompress it
// if it is a recognized format.
QImage image = QImage::fromData({ compressedImage->data, compressedImage->size });
if (!image.isNull())
return image;
}
return {};
}
} // namespace
void MediaDataHolder::updateMetaData()
{
m_metaData = {};
if (!m_context)
return;
m_metaData = QFFmpegMetaData::fromAVMetaData(m_context->metadata);
m_metaData.insert(QMediaMetaData::FileFormat,
QVariant::fromValue(QFFmpegMediaFormatInfo::fileFormatForAVInputFormat(
*m_context->iformat)));
m_metaData.insert(QMediaMetaData::Duration, toUserDuration(m_duration).get());
if (!m_cachedThumbnail.has_value())
m_cachedThumbnail = getAttachedPicture(m_context.get());
QtMultimediaPrivate::setCoverArtImage(m_metaData, *m_cachedThumbnail);
for (auto trackType : { TrackType::AudioStream,
TrackType::VideoStream }) {
const auto streamIndex = m_currentAVStreamIndex[trackType];
if (streamIndex >= 0)
insertMediaData(m_metaData, trackType, m_context->streams[streamIndex]);
}
}
bool MediaDataHolder::setActiveTrack(TrackType type, int streamNumber)
{
if (!m_context)
return false;
if (streamNumber < 0 || streamNumber >= m_streamMap[type].size())
streamNumber = -1;
if (m_requestedStreams[type] == streamNumber)
return false;
m_requestedStreams[type] = streamNumber;
const int avStreamIndex = m_streamMap[type].value(streamNumber).avStreamIndex;
const int oldIndex = m_currentAVStreamIndex[type];
qCDebug(qLcMediaDataHolder) << ">>>>> change track" << type << "from" << oldIndex << "to"
<< avStreamIndex;
// TODO: maybe add additional verifications
m_currentAVStreamIndex[type] = avStreamIndex;
updateMetaData();
return true;
}
int MediaDataHolder::activeTrack(TrackType type) const
{
return m_requestedStreams[type];
}
const QList<MediaDataHolder::StreamInfo> &MediaDataHolder::streamInfo(
TrackType trackType) const
{
return m_streamMap[trackType];
}
} // namespace QFFmpeg
QT_END_NAMESPACE