Revision control
Copy as Markdown
// Copyright (C) 2026 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 "qwasmvideoframegrabber_p.h"
#include "qwasmvideooutput_p.h"
#include "qwasmgltexturevideobuffer_p.h"
#include <QtMultimedia/qvideosink.h>
#include <QtMultimedia/private/qmemoryvideobuffer_p.h>
#include <QtMultimedia/private/qvideoframe_p.h>
#include <QtGui/qopenglcontext.h>
#include <QtGui/rhi/qrhi_platform.h>
#include <QtCore/qdebug.h>
#include <QtCore/private/qstdweb_p.h>
#include <emscripten/emscripten.h>
#include <emscripten/em_js.h>
#include <emscripten/html5.h>
#include <emscripten/val.h>
#include <GLES2/gl2.h>
QT_BEGIN_NAMESPACE
using namespace emscripten;
using namespace Qt::Literals;
// EM_JS pointer arguments are BigInt on wasm64, Number() makes them usable on both targets.
// Upload the current video frame to the already-bound TEXTURE_2D.
EM_JS(void, em_texImage2DFromVideo, (const char *videoId, int *widthOut, int *heightOut), {
videoId = Number(videoId);
widthOut = Number(widthOut);
heightOut = Number(heightOut);
var gl = GL.currentContext.GLctx;
var video = document.getElementById(UTF8ToString(videoId));
if (!video) { return; }
var frame;
try { frame = new VideoFrame(video); } catch(e) { return; }
HEAP32[widthOut / 4] = frame.displayWidth;
HEAP32[heightOut / 4] = frame.displayHeight;
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, frame);
frame.close();
});
QWasmVideoFrameGrabber::QWasmVideoFrameGrabber(QWasmVideoOutput *videoOutput)
: m_videoOutput(videoOutput)
{
}
void QWasmVideoFrameGrabber::detectWebGLContext()
{
m_glContextHandle = 0;
m_hasWebGLContext = false;
QRhi *rhi = m_videoOutput->m_wasmSink ? m_videoOutput->m_wasmSink->rhi() : nullptr;
if (!rhi || rhi->backend() != QRhi::OpenGLES2)
return;
const auto *nativeHandles = static_cast<const QRhiGles2NativeHandles *>(rhi->nativeHandles());
if (!nativeHandles || !nativeHandles->context)
return;
QOpenGLContext *openGLContext = nativeHandles->context;
auto tryGetHandleFromSurface = [&]() -> bool {
QSurface *surface = openGLContext->surface();
if (!surface)
return false;
// QWasmOpenGLContext::makeCurrent() ends in
// emscripten_webgl_make_context_current(), so the handle we want is the
// one that is current afterwards.
if (QOpenGLContext::currentContext() != openGLContext
&& !openGLContext->makeCurrent(surface))
return false;
m_glContextHandle = emscripten_webgl_get_current_context();
m_hasWebGLContext = (m_glContextHandle != 0);
return m_hasWebGLContext;
};
if (!tryGetHandleFromSurface())
qWarning() << Q_FUNC_INFO << "could not determine the WebGL context for the current RHI";
}
// software path, copies VideoFrame data into a QMemoryVideoBuffer
void QWasmVideoFrameGrabber::processVideoFrame()
{
emscripten::val videoElement = m_videoOutput->currentVideoElement();
// The VideoFrame constructor throws InvalidStateError when the browser compositor
// has not yet committed the first decoded frame, even if readyState == 4 and
// videoWidth > 0. Use a JS try-catch so the exception does not propagate into
// the wasm runtime and abort the application.
emscripten::val oneVideoFrame = emscripten::val::take_ownership(
static_cast<EM_VAL>(EM_ASM_PTR({
try {
return Emval.toHandle(new VideoFrame(Emval.toValue($0)));
} catch(e) {
return Emval.toHandle(null);
}
}, videoElement.as_handle())));
if (oneVideoFrame.isNull() || oneVideoFrame.isUndefined()) {
qCDebug(qWasmMediaVideoOutput) << Q_FUNC_INFO << "VideoFrame not ready yet, skipping";
return;
}
emscripten::val options = emscripten::val::object();
emscripten::val rectOptions = emscripten::val::object();
int displayWidth = oneVideoFrame["displayWidth"].as<int>();
int displayHeight = oneVideoFrame["displayHeight"].as<int>();
rectOptions.set("width", displayWidth);
rectOptions.set("height", displayHeight);
options.set("rect", rectOptions);
emscripten::val frameBytesAllocationSize = oneVideoFrame.call<emscripten::val>("allocationSize", options);
emscripten::val frameBuffer =
emscripten::val::global("Uint8Array").new_(frameBytesAllocationSize);
QWasmVideoOutput *videoOutput = m_videoOutput;
qstdweb::PromiseCallbacks copyToCallback;
copyToCallback.thenFunc = [videoOutput, oneVideoFrame, frameBuffer,
displayWidth, displayHeight]
(emscripten::val frameLayout)
{
if (frameLayout.isNull() || frameLayout.isUndefined()) {
qCDebug(qWasmMediaVideoOutput) << "theres no frameLayout";
return;
}
// frameBuffer now has a new frame, send to Qt
const QSize frameSize(displayWidth, displayHeight);
QByteArray frameBytes = QByteArray::fromEcmaUint8Array(frameBuffer);
QVideoFrameFormat::PixelFormat pixelFormat =
fromJsPixelFormat(oneVideoFrame["format"].as<std::string>());
if (pixelFormat == QVideoFrameFormat::Format_Invalid)
pixelFormat = QVideoFrameFormat::Format_RGBA8888;
QVideoFrameFormat frameFormat = QVideoFrameFormat(frameSize, pixelFormat);
if (videoOutput->m_useCameraRotation)
frameFormat.setRotation(videoOutput->m_rotateBy);
if (videoOutput->m_streamFrameRate > 0)
frameFormat.setStreamFrameRate(videoOutput->m_streamFrameRate);
auto buffer = std::make_unique<QMemoryVideoBuffer>(
std::move(frameBytes),
frameLayout[0]["stride"].as<int>());
QVideoFrame vFrame =
QVideoFramePrivate::createFrame(std::move(buffer), std::move(frameFormat));
if (!videoOutput->m_wasmSink) {
qWarning() << "ERROR ALERT!! video sink not set";
return;
}
videoOutput->m_wasmSink->setVideoFrame(vFrame);
oneVideoFrame.call<emscripten::val>("close");
};
copyToCallback.catchFunc = [oneVideoFrame](emscripten::val error)
{
qCDebug(qWasmMediaVideoOutput) << "copyTo error"
<< QString::fromStdString(error["name"].as<std::string>())
<< QString::fromStdString(error["message"].as<std::string>());
oneVideoFrame.call<emscripten::val>("close");
};
qstdweb::Promise::make(oneVideoFrame, u"copyTo"_s, std::move(copyToCallback), frameBuffer, options);
}
// zero-readback path, uploads the VideoFrame straight into a GL texture
void QWasmVideoFrameGrabber::processWebGLVideoFrame()
{
emscripten_webgl_make_context_current(m_glContextHandle);
GLuint rawTextureId = 0;
glGenTextures(1, &rawTextureId);
QGlTextureHandle textureHandle{ rawTextureId };
glBindTexture(GL_TEXTURE_2D, textureHandle.get());
int width = 0, height = 0;
em_texImage2DFromVideo(m_videoOutput->m_videoSurfaceId.c_str(), &width, &height);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glBindTexture(GL_TEXTURE_2D, 0);
if (!textureHandle || width == 0 || height == 0) {
qCWarning(qWasmMediaVideoOutput) << "VideoFrame upload failed";
return;
}
std::unique_ptr<QHwVideoBuffer> hwBuffer =
std::make_unique<QWasmGLTextureVideoBuffer>(
std::move(textureHandle), QSize(width, height),
m_glContextHandle,
m_videoOutput->m_wasmSink ? m_videoOutput->m_wasmSink->rhi() : nullptr);
QVideoFrameFormat frameFormat(QSize(width, height), QVideoFrameFormat::Format_RGBA8888);
if (m_videoOutput->m_streamFrameRate > 0)
frameFormat.setStreamFrameRate(m_videoOutput->m_streamFrameRate);
QVideoFrame vFrame =
QVideoFramePrivate::createFrame(std::move(hwBuffer), std::move(frameFormat));
m_videoOutput->m_wasmSink->setVideoFrame(vFrame);
}
void QWasmVideoFrameGrabber::startFrameLoop()
{
if (!m_webGLContextChecked) {
m_webGLContextChecked = true;
detectWebGLContext();
}
m_videoOutput->detectIosCameraRotation();
// Single-shot callback: re-registers each frame so multiple QWasmVideoOutput
// instances can coexist. emscripten_request_animation_frame_loop allows only one
// active loop globally and would cancel another instance.
static EM_BOOL (*frame)(double, void *) = [](double frameTime, void *context) -> EM_BOOL {
Q_UNUSED(frameTime);
QWasmVideoFrameGrabber *frameGrabber = reinterpret_cast<QWasmVideoFrameGrabber *>(context);
QWasmVideoOutput *videoOutput = frameGrabber ? frameGrabber->m_videoOutput : nullptr;
if (!videoOutput || videoOutput->m_isStopped) {
qCWarning(qWasmMediaVideoOutput) << "frame loop exit: isStopped=" << (videoOutput ? videoOutput->m_isStopped : true)
<< "mode=" << (videoOutput ? videoOutput->m_currentVideoMode : -1);
return false;
}
if (videoOutput->m_currentVideoMode == QWasmVideoOutput::VideoDisplay
&& videoOutput->m_currentMediaStatus != QWasmVideoOutput::MediaStatus::LoadedMedia) {
emscripten_request_animation_frame(frame, context);
return true;
}
emscripten::val videoElement = videoOutput->currentVideoElement();
if (videoElement.isNull() || videoElement.isUndefined()) {
qCWarning(qWasmMediaVideoOutput) << "frame loop exit: video element null, mode=" << videoOutput->m_currentVideoMode;
return false;
}
if (videoElement["paused"].as<bool>() || videoElement["ended"].as<bool>()
|| videoElement["readyState"].as<int>() < 2) {
qCDebug(qWasmMediaVideoOutput) << "frame loop waiting: mode=" << videoOutput->m_currentVideoMode
<< "paused=" << videoElement["paused"].as<bool>()
<< "ended=" << videoElement["ended"].as<bool>()
<< "readyState=" << videoElement["readyState"].as<int>();
emscripten_request_animation_frame(frame, context);
return true;
}
if (frameGrabber->m_glContextHandle)
frameGrabber->processWebGLVideoFrame();
else
frameGrabber->processVideoFrame();
emscripten_request_animation_frame(frame, context);
return true;
};
if ((!m_videoOutput->m_isStopped
&& m_videoOutput->m_video["className"].as<std::string>() == "Camera"
&& m_videoOutput->m_cameraIsReady)
|| (!m_videoOutput->m_isStopped
&& m_videoOutput->m_currentVideoMode == QWasmVideoOutput::SurfaceCapture)
|| m_videoOutput->isReady())
emscripten_request_animation_frame(frame, this);
}
QVideoFrameFormat::PixelFormat QWasmVideoFrameGrabber::fromJsPixelFormat(std::string_view videoFormat)
{
if (videoFormat == "I420")
return QVideoFrameFormat::Format_YUV420P;
// no equivalent pixel format
// else if (videoFormat == "I420A") // AYUV ?
else if (videoFormat == "I422")
return QVideoFrameFormat::Format_YUV422P;
// no equivalent pixel format
// else if (videoFormat == "I444")
else if (videoFormat == "NV12")
return QVideoFrameFormat::Format_NV12;
else if (videoFormat == "RGBA")
return QVideoFrameFormat::Format_RGBA8888;
else if (videoFormat == "RGBX")
return QVideoFrameFormat::Format_RGBX8888;
else if (videoFormat == "BGRA")
return QVideoFrameFormat::Format_BGRA8888;
else if (videoFormat == "BGRX")
return QVideoFrameFormat::Format_BGRX8888;
return QVideoFrameFormat::Format_Invalid;
}
QT_END_NAMESPACE