Revision control

Copy as Markdown

// Copyright (C) 2022 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 "qwasmvideooutput_p.h"
#include <QtMultimedia/qmediaplayer.h>
#include <QtMultimedia/qvideoframe.h>
#include <QtMultimedia/qvideosink.h>
#include <QtMultimedia/private/qplatformvideosink_p.h>
#include <QtGui/qguiapplication.h>
#include <QtGui/qopenglcontext.h>
#include <QtGui/qpa/qplatformwindow_p.h>
#include <QtGui/rhi/qrhi_platform.h>
#include <QtCore/qbuffer.h>
#include <QtCore/qdebug.h>
#include <QtCore/qfile.h>
#include <QtCore/qmimedatabase.h>
#include <QtCore/qpoint.h>
#include <QtCore/qpointer.h>
#include <QtCore/qrect.h>
#include <QtCore/qtimer.h>
#include <QtCore/qurl.h>
#include <QtCore/private/qstdweb_p.h>
#include <emscripten/bind.h>
#include <emscripten/val.h>
#include <GLES2/gl2.h>
QT_BEGIN_NAMESPACE
using namespace emscripten;
using namespace Qt::Literals;
Q_LOGGING_CATEGORY(qWasmMediaVideoOutput, "qt.multimedia.wasm.videooutput")
bool QWasmVideoOutput::isPlatformiOs()
{
emscripten::val platformObject = emscripten::val::global("navigator")["platform"];
if (platformObject.call<bool>("includes", emscripten::val("iPhone"))
|| platformObject.call<bool>("includes", emscripten::val("iPad")))
return true;
return false;
}
QWasmVideoOutput::QWasmVideoOutput(QObject *parent)
: QObject{ parent }, m_frameGrabber(this)
{
if (isPlatformiOs()) {
connect(this, &QWasmVideoOutput::orientationChanged, this,
&QWasmVideoOutput::applyIosRotation);
emscripten_set_orientationchange_callback(this, false, &QWasmVideoOutput::orientationchangeCallback);
}
}
QWasmVideoOutput::~QWasmVideoOutput()
{
if (m_mediaInputStream) {
if (m_streamStarted) {
m_streamStarted = false;
m_mediaInputStream->unregisterConsumer();
}
JsMediaInputStream::releaseInstance(m_cameraId);
}
}
void QWasmVideoOutput::setVideoSize(const QSize &newSize)
{
if (m_pendingVideoSize == newSize)
return;
m_pendingVideoSize = newSize;
updateVideoElementGeometry(QRect(0, 0, m_pendingVideoSize.width(), m_pendingVideoSize.height()));
}
void QWasmVideoOutput::setVideoMode(QWasmVideoOutput::WasmVideoMode mode)
{
m_currentVideoMode = mode;
}
// Calls play() on the video element and handles the returned promise.
//
// A play() that is interrupted by a new load request (e.g. a fresh srcObject or
// a load() call) rejects with AbortError. That rejection is benign, but if the
// promise is left unhandled the browser reports it as an uncaught rejection.
//
// A play() that is refused outright is not benign, the element stays unplayable
// and fires no further media events, so nothing else would take the player out
// of the state it is in. Report those instead of swallowing them.
void QWasmVideoOutput::playVideoElement()
{
emscripten::val promise = m_video.call<emscripten::val>("play");
// Older browsers return undefined from play() instead of a Promise. Only
// attach handlers when we actually got a Promise back.
if (promise.isUndefined() || promise.isNull() || promise["then"].isUndefined())
return;
QPointer<QWasmVideoOutput> videoOutput(this);
qstdweb::Promise::adoptPromise(promise, {
.catchFunc = [videoOutput](emscripten::val error) {
if (!videoOutput || videoOutput->m_isStopped)
return;
// The rejection value is not guaranteed to be a DOMException, so do
// not assume either property is there.
const auto stringProperty = [&error](const char *key) {
return error[key].isUndefined() || error[key].isNull()
? QString()
: QString::fromStdString(error[key].as<std::string>());
};
const QString errorName = stringProperty("name");
const QString errorMessage = stringProperty("message");
// The browsers word this differently and some do not mention play()
// at all, so keep the origin in the string the application sees.
const QString errorString =
"video.play(): "_L1 + (errorMessage.isEmpty() ? errorName : errorMessage);
// Not being allowed to play is a policy refusal, not an interruption.
// The element is not sitting at a resume point, it is unplayable
// until the user acts, so report it regardless of what the paused
// attribute happens to say.
if (errorName == "NotAllowedError"_L1) {
qCWarning(qWasmMediaVideoOutput) << "video.play() not allowed:" << errorMessage;
Q_EMIT videoOutput->readyChanged(false);
Q_EMIT videoOutput->stateChanged(QWasmMediaPlayer::Stopped);
Q_EMIT videoOutput->errorOccured(QMediaPlayer::AccessDeniedError, errorString);
return;
}
// Anything else is delivered a microtask late, and an AbortError from
// a new load request is routinely followed by a fresh play() that has
// already succeeded by now. Trust the element over the rejection.
if (!videoOutput->m_video["paused"].as<bool>()) {
qCDebug(qWasmMediaVideoOutput) << "video.play() rejected with" << errorName
<< "but playback resumed, ignoring";
return;
}
qCWarning(qWasmMediaVideoOutput) << "video.play() rejected:" << errorName
<< errorMessage;
Q_EMIT videoOutput->readyChanged(false);
Q_EMIT videoOutput->stateChanged(QWasmMediaPlayer::Stopped);
Q_EMIT videoOutput->errorOccured(QMediaPlayer::ResourceError, errorString);
}
});
}
void QWasmVideoOutput::start()
{
if (m_video.isUndefined() || m_video.isNull()
|| !m_wasmSink) {
// error
Q_EMIT errorOccured(QMediaPlayer::ResourceError, QStringLiteral("video surface error"));
return;
}
switch (m_currentVideoMode) {
case QWasmVideoOutput::VideoDisplay: {
emscripten::val sourceObj = m_video["src"];
if ((sourceObj.isUndefined() || sourceObj.isNull()) && !m_source.isEmpty()) {
m_video.set("src", m_source);
}
if (!isReady())
m_video.call<void>("load");
} break;
case QWasmVideoOutput::SurfaceCapture: {
playVideoElement();
Q_EMIT readyChanged(true);
} break;
case QWasmVideoOutput::Camera: {
{
emscripten::val document = emscripten::val::global("document");
if (m_video["parentNode"].isNull() || m_video["parentNode"].isUndefined())
document["body"].call<void>("appendChild", m_video);
}
if (!m_cameraIsReady) {
m_shouldBeStarted = true;
}
if (!m_connection)
m_connection = connect(m_mediaInputStream, &JsMediaInputStream::mediaVideoStreamReady, this,
[=]( ) {
emscripten::val newStream = m_mediaInputStream->getMediaStream();
// mediaVideoStreamReady may fire again for an already-attached
// stream (e.g. a restart on a still-active camera). Re-setting
// srcObject would interrupt the in-flight play() and reject its
// promise with AbortError, so skip it when nothing changed.
if (m_video["srcObject"].equals(newStream)) {
Q_EMIT readyChanged(true);
return;
}
m_video.set("srcObject", newStream);
emscripten::val stream = m_video["srcObject"];
if (stream.isNull() || stream.isUndefined()) { // camera device
qCDebug(qWasmMediaVideoOutput) << "srcObject ERROR";
Q_EMIT errorOccured(QMediaPlayer::ResourceError, QStringLiteral("video surface error"));
return;
} else {
emscripten::val videoTracks = stream.call<emscripten::val>("getVideoTracks");
if (videoTracks.isNull() || videoTracks.isUndefined()) {
qCDebug(qWasmMediaVideoOutput) << Q_FUNC_INFO << "videoTracks is null";
Q_EMIT errorOccured(QMediaPlayer::ResourceError,
QStringLiteral("video surface error"));
return;
}
if (videoTracks["length"].as<int>() == 0) {
qCDebug(qWasmMediaVideoOutput) << Q_FUNC_INFO << "videoTracks count is 0";
Q_EMIT errorOccured(QMediaPlayer::ResourceError,
QStringLiteral("video surface error"));
return;
}
emscripten::val videoSettings = videoTracks[0].call<emscripten::val>("getSettings");
if (!videoSettings.isNull() && !videoSettings.isUndefined()) {
const int width = videoSettings["width"].as<int>();
const int height = videoSettings["height"].as<int>();
updateVideoElementGeometry(QRect(0, 0, width, height));
if (!videoSettings["frameRate"].isUndefined())
m_streamFrameRate = videoSettings["frameRate"].as<double>();
}
}
playVideoElement();
Q_EMIT readyChanged(true);
});
m_mediaInputStream->setUseAudio(false);
m_shouldBeStarted = true;
m_mediaInputStream->setVideoConstraints(m_videoResolution, m_minFrameRate, m_maxFrameRate);
if (!m_streamStarted) {
m_streamStarted = true;
m_mediaInputStream->registerConsumer();
}
m_mediaInputStream->setStreamDevice(m_cameraId);
} break;
};
m_isStopped = false;
if (m_currentVideoMode != QWasmVideoOutput::Camera
&& m_currentVideoMode != QWasmVideoOutput::SurfaceCapture) {
playVideoElement();
}
}
void QWasmVideoOutput::stop()
{
if (m_isStopped)
return;
qCWarning(qWasmMediaVideoOutput) << Q_FUNC_INFO << "mode=" << m_currentVideoMode;
if (m_video.isUndefined() || m_video.isNull()) {
Q_EMIT errorOccured(QMediaPlayer::ResourceError, QStringLiteral("Resource error"));
return;
}
m_isStopped = true;
if (!m_toBePaused) {
if (m_currentVideoMode == QWasmVideoOutput::SurfaceCapture) {
emscripten::val stream = m_video["srcObject"];
if (!stream.isNull() && !stream.isUndefined()) {
emscripten::val tracks = stream.call<emscripten::val>("getTracks");
const int count = tracks["length"].as<int>();
for (int i = 0; i < count; ++i)
tracks[i].call<void>("stop");
}
} else if (m_mediaInputStream && m_streamStarted) {
// Only stop the shared MediaStream once the last consumer of this
// camera goes away; other displays of the same camera keep running.
m_streamStarted = false;
m_mediaInputStream->unregisterConsumer();
}
m_video.set("srcObject", emscripten::val::null());
disconnect(m_connection);
m_connection = {};
m_video.call<void>("remove");
} else {
m_video.call<void>("pause");
}
}
void QWasmVideoOutput::pause()
{
qCDebug(qWasmMediaVideoOutput) << Q_FUNC_INFO;
if (m_video.isUndefined() || m_video.isNull()) {
// error
Q_EMIT errorOccured(QMediaPlayer::ResourceError, QStringLiteral("video surface error"));
return;
}
m_isStopped = false;
m_toBePaused = true;
m_video.call<void>("pause");
}
void QWasmVideoOutput::reset()
{
// flush pending frame
if (m_wasmSink)
m_wasmSink->platformVideoSink()->setVideoFrame(QVideoFrame());
m_source.clear();
m_video.set("currentTime", emscripten::val(0));
m_video.call<void>("load");
}
emscripten::val QWasmVideoOutput::surfaceElement()
{
return m_video;
}
void QWasmVideoOutput::setSurface(QVideoSink *surface)
{
if (!surface || surface == m_wasmSink) {
return;
}
m_wasmSink = surface;
}
bool QWasmVideoOutput::isReady() const
{
if (m_video.isUndefined() || m_video.isNull()) {
// error
return false;
}
return m_currentMediaStatus == MediaStatus::LoadedMedia;
}
void QWasmVideoOutput::setSource(const QUrl &url)
{
qCDebug(qWasmMediaVideoOutput) << Q_FUNC_INFO << url;
m_source = url.toString();
if (m_video.isUndefined() || m_video.isNull()) {
return;
}
if (url.isEmpty()) {
stop();
return;
}
if (url.isLocalFile()) {
QFile localFile(url.toLocalFile());
if (localFile.open(QIODevice::ReadOnly)) {
setSource(&localFile);
} else {
qWarning() << "Failed to open file";
}
return;
}
updateVideoElementSource(m_source);
}
void QWasmVideoOutput::updateVideoElementSource(const QString &src)
{
m_video.set("src", src.toStdString());
m_video.call<void>("load");
}
void QWasmVideoOutput::addCameraSourceElement(const std::string &id)
{
m_cameraIsReady = false;
if (m_mediaInputStream)
JsMediaInputStream::releaseInstance(m_cameraId);
m_mediaInputStream = JsMediaInputStream::instance(id);
m_mediaInputStream->setUseAudio(m_hasAudio);
m_mediaInputStream->setUseVideo(true);
connect(m_mediaInputStream, &JsMediaInputStream::mediaVideoStreamReady, this,
[this]() {
qCDebug(qWasmMediaVideoOutput) << "mediaVideoStreamReady" << m_shouldBeStarted;
m_cameraIsReady = true;
if (m_shouldBeStarted) {
start();
m_shouldBeStarted = false;
}
});
m_cameraId = id;
}
void QWasmVideoOutput::setVideoConstraints(QSize resolution, float minFrameRate, float maxFrameRate)
{
m_videoResolution = resolution;
m_minFrameRate = minFrameRate;
m_maxFrameRate = maxFrameRate;
}
void QWasmVideoOutput::setSource(QIODevice *stream)
{
if (stream->bytesAvailable() == 0) {
qWarning() << "data not available";
Q_EMIT errorOccured(QMediaPlayer::ResourceError, QStringLiteral("data not available"));
return;
}
if (m_video.isUndefined() || m_video.isNull()) {
Q_EMIT errorOccured(QMediaPlayer::ResourceError, QStringLiteral("video surface error"));
return;
}
QMimeDatabase db;
QMimeType mime = db.mimeTypeForData(stream);
QByteArray buffer = stream->readAll();
qstdweb::Blob contentBlob = qstdweb::Blob::copyFrom(buffer.data(), buffer.size(), mime.name().toStdString());
emscripten::val window = qstdweb::window();
if (window["safari"].isUndefined()) {
emscripten::val contentUrl = window["URL"].call<emscripten::val>("createObjectURL", contentBlob.val());
m_video.set("src", contentUrl);
m_source = QString::fromStdString(contentUrl.as<std::string>());
} else {
// only Safari currently supports Blob with srcObject
m_video.set("srcObject", contentBlob.val());
}
}
void QWasmVideoOutput::setVolume(qreal volume)
{ // between 0 - 1
volume = qBound(qreal(0.0), volume, qreal(1.0));
m_video.set("volume", volume);
}
void QWasmVideoOutput::setMuted(bool muted)
{
if (m_video.isUndefined() || m_video.isNull()) {
// error
Q_EMIT errorOccured(QMediaPlayer::ResourceError, QStringLiteral("video surface error"));
return;
}
m_video.set("muted", muted);
}
qint64 QWasmVideoOutput::getCurrentPosition()
{
return (!m_video.isUndefined() || !m_video.isNull())
? (m_video["currentTime"].as<double>() * 1000)
: 0;
}
void QWasmVideoOutput::seekTo(qint64 positionMSecs)
{
if (isVideoSeekable()) {
float positionToSetInSeconds = float(positionMSecs) / 1000;
emscripten::val seekableTimeRange = m_video["seekable"];
if (!seekableTimeRange.isNull() || !seekableTimeRange.isUndefined()) {
// range user can seek
if (seekableTimeRange["length"].as<int>() < 1)
return;
if (positionToSetInSeconds
>= seekableTimeRange.call<emscripten::val>("start", 0).as<double>()
&& positionToSetInSeconds
<= seekableTimeRange.call<emscripten::val>("end", 0).as<double>()) {
m_requestedPosition = positionToSetInSeconds;
m_video.set("currentTime", m_requestedPosition);
}
}
}
qCDebug(qWasmMediaVideoOutput) << "m_requestedPosition" << m_requestedPosition;
}
bool QWasmVideoOutput::isVideoSeekable()
{
if (m_video.isUndefined() || m_video.isNull()) {
// error
Q_EMIT errorOccured(QMediaPlayer::ResourceError, QStringLiteral("video surface error"));
return false;
}
emscripten::val seekableTimeRange = m_video["seekable"];
if (seekableTimeRange["length"].as<int>() < 1)
return false;
if (!seekableTimeRange.isNull() || !seekableTimeRange.isUndefined()) {
bool isit = !QtPrivate::fuzzyCompare(
seekableTimeRange.call<emscripten::val>("start", 0).as<double>(),
seekableTimeRange.call<emscripten::val>("end", 0).as<double>());
return isit;
}
return false;
}
void QWasmVideoOutput::createVideoElement(const std::string &id)
{
qCDebug(qWasmMediaVideoOutput) << Q_FUNC_INFO << this << id;
// Create <video> element and add it to the page body
emscripten::val document = emscripten::val::global("document");
emscripten::val body = document["body"];
// remove any previously created video element for this output
if (!m_video.isUndefined() && !m_video.isNull())
m_video.call<void>("remove");
m_videoSurfaceId = id;
m_video = document.call<emscripten::val>("createElement", std::string("video"));
m_video.set("id", m_videoSurfaceId.c_str());
m_video.call<void>("setAttribute", std::string("class"),
(m_currentVideoMode == QWasmVideoOutput::Camera ? std::string("Camera")
: std::string("Video")));
m_video.set("preload", "metadata");
// Uncaught DOMException: Failed to execute 'getImageData' on
// 'OffscreenCanvasRenderingContext2D': The canvas has been tainted by
// cross-origin data.
// TODO figure out somehow to let user choose between these
std::string originString = "anonymous"; // requires server Access-Control-Allow-Origin *
// std::string originString = "use-credentials"; // must not
// Access-Control-Allow-Origin *
m_video.call<void>("setAttribute", std::string("crossorigin"), originString);
body.call<void>("appendChild", m_video);
// Create/add video source
document.call<emscripten::val>("createElement",
std::string("source")).set("src", m_source.toStdString());
// Set position:absolute, which makes it possible to position the video
// element using x,y. coordinates, relative to its parent (the page's <body>
// element)
emscripten::val style = m_video["style"];
style.set("position", "absolute");
style.set("display", "none"); // hide
if (!m_source.isEmpty())
updateVideoElementSource(m_source);
}
void QWasmVideoOutput::removeCurrentVideoElement()
{
if (!m_video.isUndefined() && !m_video.isNull())
m_video.call<void>("remove");
}
void QWasmVideoOutput::updateVideoElementGeometry(const QRect &windowGeometry)
{
QRect videoElementRect(windowGeometry.topLeft(), windowGeometry.size());
emscripten::val style = m_video["style"];
style.set("left", QStringLiteral("%1px").arg(videoElementRect.left()).toStdString());
style.set("top", QStringLiteral("%1px").arg(videoElementRect.top()).toStdString());
m_video.set("width", videoElementRect.width());
m_video.set("height", videoElementRect.height());
style.set("z-index", "999");
}
qint64 QWasmVideoOutput::getDuration()
{
// qt duration is in ms
// js is sec
if (m_video.isUndefined() || m_video.isNull())
return 0;
return m_video["duration"].as<double>() * 1000;
}
void QWasmVideoOutput::setPlaybackRate(qreal rate)
{
m_video.set("playbackRate", emscripten::val(rate));
}
qreal QWasmVideoOutput::playbackRate()
{
return (m_video.isUndefined() || m_video.isNull()) ? 0 : m_video["playbackRate"].as<float>();
}
emscripten::val QWasmVideoOutput::getDeviceCapabilities()
{
emscripten::val stream = m_video["srcObject"];
if ((!stream.isNull() && !stream.isUndefined()) && stream["active"].as<bool>()) {
emscripten::val tracks = stream.call<emscripten::val>("getVideoTracks");
if (!tracks.isUndefined()) {
if (tracks["length"].as<int>() == 0)
return emscripten::val::undefined();
emscripten::val track = tracks[0];
if (!track.isUndefined()) {
emscripten::val trackCaps = emscripten::val::undefined();
if (!track["getCapabilities"].isUndefined())
trackCaps = track.call<emscripten::val>("getCapabilities");
else // firefox does not support getCapabilities
trackCaps = track.call<emscripten::val>("getSettings");
if (!trackCaps.isUndefined())
return trackCaps;
}
}
} else {
// camera not started track capabilities not available
Q_EMIT errorOccured(QMediaPlayer::ResourceError, QStringLiteral("capabilities not available"));
}
return emscripten::val::undefined();
}
bool QWasmVideoOutput::setDeviceSetting(const std::string &key, emscripten::val value)
{
emscripten::val stream = m_video["srcObject"];
if (stream.isNull() || stream.isUndefined()
|| stream["getVideoTracks"].isUndefined())
return false;
emscripten::val tracks = stream.call<emscripten::val>("getVideoTracks");
if (!tracks.isNull() || !tracks.isUndefined()) {
if (tracks["length"].as<int>() == 0)
return false;
emscripten::val track = tracks[0];
emscripten::val contraint = emscripten::val::object();
contraint.set(std::move(key), value);
track.call<emscripten::val>("applyConstraints", contraint);
return true;
}
return false;
}
QT_END_NAMESPACE
#include "moc_qwasmvideooutput_p.cpp"