WGC捕获迁移到Electron
迁移
上个笔记里我们用了WINRT、D3D和glfw制作了一个WGC的窗口捕获程序,但是现在如果要把功能给迁移到electron应用中去的话,就得去掉glfw,这个是多余的功能,我们可以保留D3D11CreateDevice、WGC、frame pool,FrameArrived、staging、Map->CPU缓冲这些功能,或者说以后做D3D纹理共享给Chromium(当做以后得目标吧)
我们需要安装一个叫做 node-addon-api 的 C++ 库,Windows 上 vcpkg 常写成 node-addon-api:x64-windows。我们要在源码中引入 napi.h(node-addon-api 包装层)来使用 Napi::Env 以及 Napi::Object / Napi::Function。编译得到的是 DLL,要给 Node/Electron 使用,通常还要把输出名改成 capture_addon.node(或你在 CMake 里设的 PREFIX "" + SUFFIX ".node")。
我们使用Imp的定义和实现分离的办法,定义一个,.h和一个.cpp文件:
#pragma once
#include <atomic>
#include <cstdint>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
struct RecorderConfig;
/// 默认显示器 WGC 捕获:D3D11 + 帧池;CPU 缓冲为 RGBA(自 BGRA 转换)。供 main / N-API 等复用。
class MonitorCapture {
struct Impl;
std::unique_ptr<Impl> impl_;
public:
MonitorCapture();
~MonitorCapture();
MonitorCapture(const MonitorCapture&) = delete;
MonitorCapture& operator=(const MonitorCapture&) = delete;
MonitorCapture(MonitorCapture&&) = delete;
MonitorCapture& operator=(MonitorCapture&&) = delete;
bool prepare_default_monitor();
void begin_capture();
void stop();
bool is_prepared() const;
int width() const;
int height() const;
std::mutex& buffer_mutex();
std::vector<uint8_t>& pixel_buffer();
std::atomic<bool>& frame_dirty();
void set_preview_enabled(bool enabled);
bool preview_enabled() const;
void set_recording_enabled(bool enabled);
bool recording_enabled() const;
/// 输出 MP4(Media Foundation)。须已 prepare + begin_capture;path 为 UTF-16 本地路径。
bool start_recording(const std::wstring& output_path, const RecorderConfig& config);
void stop_recording();
bool is_recording() const;
};
说明(和旧笔记的差别)。 捕获逻辑仍然是一段 Impl + FrameArrived,但实现文件已经 main_n.cpp(命名上和 GLFW 演示用的 main.cpp 区分开)。同一工程里后来叠了:
- 预览:
set_preview_enabled,帧里可选择只喂录制、仍然更新pixelBuffer给 Canvas。 - 录制:
MediaRecorder写 H.264/AAC MP4;WASAPI loopback 抓「播放设备上听到的」混音,混音多为 float 时在回调里夹一层 float→int16 再送进 MF。
MSVC 下若写了 std::max, Windows.h 里的 min/max 宏会把模板函数顶坏,所以在 #include <Windows.h> 之前 建议定义 NOMINMAX(我在 main_n.cpp 里就是这么干的);否则你会看到 C2589 一类语法错误。
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
// ...
const UINT32 ch = (std::max)(1u, m_audioChannelsFromDevice); // 括号也可防宏展开
完整 MonitorCapture::Impl(含 D3D staging、WGC、WasapiLoopback、feed_wasapi_audio)以仓库 main_n.cpp 为准,这里不再整段粘贴,避免下次又和线上漂移。
因此我们需要把方法包装成 Napi::Value / Napi::Function 再绑到 exports 上。当前仓库里 addon.cpp 除了 start / stop / getFrame / getSize,还多了 startRecording / stopRecording / isRecording,以及枚举播放设备的 getAudioOutputDevices(给 Vue 里下拉选 loopback 用)。录制参数里可选 audioOutputDeviceId(UTF-8),对应 RecorderConfig::audioOutputDeviceId(宽字符串)。
下面这一段是「骨架」示意:UTF-8 路径宽字符转换、ParseRecorderConfig、InitializeModule 里多挂了哪些名字——细节仍以仓库 addon.cpp 为准。
#include <napi.h>
#include <winrt/base.h>
#include <Windows.h>
#include <memory>
#include <mutex>
#include <string>
#include "media_recorder.h"
#include "screen_capture.h"
#include "audio_output_devices.h"
namespace {
std::unique_ptr<MonitorCapture> g_capture;
std::once_flag g_winrt_apartment_once;
static void ensure_winrt_apartment()
{
std::call_once(g_winrt_apartment_once, []() {
try {
winrt::init_apartment(winrt::apartment_type::multi_threaded);
}
catch (winrt::hresult_error const& e) {
const auto c = static_cast<uint32_t>(e.code());
if (c != 0x80010106U)
throw;
// RPC_E_CHANGED_MODE:Electron 已初始化 COM,沿用现有单元即可
}
});
}
static RecorderConfig ParseRecorderConfig(Napi::Object const& o)
{
RecorderConfig cfg{};
cfg.width = o.Get("width").As<Napi::Number>().Uint32Value();
cfg.height = o.Get("height").As<Napi::Number>().Uint32Value();
cfg.fps = o.Get("fps").As<Napi::Number>().Uint32Value();
if (o.Has("videoBitrate"))
cfg.videoBitrate = o.Get("videoBitrate").As<Napi::Number>().Uint32Value();
if (o.Has("audioSampleRate"))
cfg.audioSampleRate = o.Get("audioSampleRate").As<Napi::Number>().Uint32Value();
if (o.Has("audioChannels"))
cfg.audioChannels = o.Get("audioChannels").As<Napi::Number>().Uint32Value();
if (o.Has("enableAudio"))
cfg.enableAudio = o.Get("enableAudio").As<Napi::Boolean>().Value();
if (o.Has("audioOutputDeviceId")) {
const std::string u8 = o.Get("audioOutputDeviceId").As<Napi::String>().Utf8Value();
if (!u8.empty())
cfg.audioOutputDeviceId = Utf8ToWide(u8); // MultiByteToWideChar CP_UTF8
}
return cfg;
}
// Start / GetFrame / Stop / GetSize:与旧笔记相同思路……
/** JS: startRecording(outputPathUtf8, config) — path 用 UTF-8 */
Napi::Value StartRecording(const Napi::CallbackInfo& info) { /* … */ }
Napi::Value GetAudioOutputDevices(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
const auto devices = EnumerateAudioRenderDevices();
Napi::Array arr = Napi::Array::New(env, devices.size());
for (size_t i = 0; i < devices.size(); ++i) {
Napi::Object row = Napi::Object::New(env);
row.Set("id", Napi::String::New(env, WideToUtf8(devices[i].id)));
row.Set("name", Napi::String::New(env, WideToUtf8(devices[i].friendlyName)));
arr.Set(static_cast<uint32_t>(i), row);
}
return arr;
}
} // namespace
Napi::Object InitializeModule(Napi::Env env, Napi::Object exports)
{
exports.Set("start", Napi::Function::New(env, Start));
exports.Set("stop", Napi::Function::New(env, Stop));
exports.Set("getFrame", Napi::Function::New(env, GetFrame));
exports.Set("getSize", Napi::Function::New(env, GetSize));
exports.Set("startRecording", Napi::Function::New(env, StartRecording));
exports.Set("stopRecording", Napi::Function::New(env, StopRecording));
exports.Set("isRecording", Napi::Function::New(env, IsRecording));
exports.Set("getAudioOutputDevices", Napi::Function::New(env, GetAudioOutputDevices));
return exports;
}
NODE_API_MODULE(capture_addon, InitializeModule)
我们使用了std::call_once,来配合std::once_flag,保证传入的初始化代码在整个进程执行一次,其他线程若随后也执行到了call_once的话,会阻塞等待第一次跑完来返回结果而不会跑第二遍。无论多少次调用 ensure_winrt_apartment()、无论从哪个线程进来,WinRT/COM 这套「初始化公寓」逻辑至多执行一次。
COM经常是按照线程来初始化的,至于为什么叫做apartment,是因为一个线程上的COM并发模型分区:
线程在参与COM之前必须声明线程大概在哪中apartment里,常见的有STA单线程、MTA多线程等。不同的Apartment里创建的COM对象,跨线程互相调用的时候要守一套排队、封送规则,就像对象被关在隔间。
头文件的来源可以用vcpkg,但是链接的时候必须与当前的Electron自带的Node ABI一致,vcpkg解决node-addon-api 头从哪来;ABI 对齐仍靠 Electron 官方流程。
我们为了适配electron版本和原生模块的关系,能够用cmake-js针对electron干净地配置并且编译+确认缓冲指向eletron以及把.node模块放在preload中,能干净地按照固定的相对路径找到位置,指定cmake里面怎么链接winrt、delay-load、win_delay_load_hook,运行时是/MD等。可以编写一个js脚本:
/**
* 必须用 cmake-js 针对 **Electron** 重新生成 CMake 缓存并链接 Electron 的 node.lib。
* 若以前用默认 Node 编过,`build/` 里会残留 NODE_RUNTIME=node,此时复制 .node 仍会一加载就崩。
*
* 用法:在 capture 目录执行 `pnpm run build:addon`
*/
import { spawnSync } from 'node:child_process'
import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const require = createRequire(import.meta.url)
const electronPkg = join(__dirname, '../node_modules/electron/package.json')
const electronVer = require(electronPkg).version
const addonRoot = join(__dirname, '../../glfwExample')
console.log('[build:addon] cmake 工程:', addonRoot)
console.log('[build:addon] Electron:', electronVer)
const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx'
function run(args, label) {
console.log('[build:addon]', label, args.join(' '))
const r = spawnSync(npx, args, { cwd: addonRoot, stdio: 'inherit', shell: true })
if ((r.status ?? 1) !== 0) {
console.error('[build:addon] 失败:', label)
process.exit(r.status ?? 1)
}
}
/** 清掉旧缓存,否则会一直链接「系统 Node」的 node.lib */
run(['cmake-js', 'clean'], 'clean')
run(
['cmake-js', 'compile', '--runtime=electron', `--runtime-version=${electronVer}`],
'compile(electron)'
)
const cacheFile = join(addonRoot, 'build/CMakeCache.txt')
if (existsSync(cacheFile)) {
const cache = readFileSync(cacheFile, 'utf8')
const rtLine = cache.split('\n').find((l) => l.startsWith('NODE_RUNTIME'))
if (!rtLine || !/electron/i.test(rtLine)) {
console.error('[build:addon] CMakeCache 未指向 electron runtime,当前行:', rtLine ?? '(无)')
console.error('[build:addon] 请先删除文件夹:', join(addonRoot, 'build'), '再重跑本脚本')
process.exit(1)
}
}
const release = join(addonRoot, 'build/Release/capture_addon.node')
const debug = join(addonRoot, 'build/Debug/capture_addon.node')
const src = existsSync(release) ? release : debug
if (!existsSync(src)) {
console.error('[build:addon] 未找到:', release, debug)
process.exit(1)
}
const dstDir = join(__dirname, '../native/wgc-addon')
const dst = join(dstDir, 'capture_addon.node')
mkdirSync(dstDir, { recursive: true })
copyFileSync(src, dst)
console.log('[build:addon] 已复制 ->', dst)
我们使用了spawnSync方法,同步运行一个子进程并且执行命令,一直等到这个命令结束,函数返回。这里的stdio:'inherit'表示子进程的标准输入/错误 直接接到当前的终端,所以我们可以看到cmake的完整输出。shell:true表示系统shell里执行,windows上能够更稳找到npx.cmd。
const cacheFile = join(addonRoot, 'build/CMakeCache.txt')
if (existsSync(cacheFile)) {
const cache = readFileSync(cacheFile, 'utf8')
const rtLine = cache.split('\n').find((l) => l.startsWith('NODE_RUNTIME'))
if (!rtLine || !/electron/i.test(rtLine)) {
console.error('[build:addon] CMakeCache 未指向 electron runtime,当前行:', rtLine ?? '(无)')
console.error('[build:addon] 请先删除文件夹:', join(addonRoot, 'build'), '再重跑本脚本')
process.exit(1)
}
}
这段使用cacheFile,cmake会把本次配置写进这个文件,里面会有NODE_RUNTIME=...之类的项,然后读文件按行找:找到以NODE_RUNTIME开头的那一行, 因为cmake-js会写入当前是按哪种runtime配置的。校验:这一行里必须能匹配 electron(不区分大小写)。
若找不到这一行,或里面是 node 而不是 electron,说明 缓存仍不对:最常见是以前用默认 Node 编过、cmake-js clean 没生效、或手动改过目录但没重新配置。
失败时:打印当前读到的行(或「无」),提示你去 删掉整个 build 文件夹 再跑脚本,然后 process.exit(1),避免后面还以为编译成功、把错的 .node 复制去 capture。
最后正常的找到.node并且复制过去。
那么我们可以在终端里初始化这个c++项目,pnpm init,然后安装一下node-addon-api,之后在运行npx node-gyp install即可
PS F:\glfwExamle\glfwExample> npx node-gyp install
Need to install the following packages:
node-gyp@12.3.0
Ok to proceed? (y) y
gyp info it worked if it ends with ok
gyp info using node-gyp@12.3.0
gyp info using node@22.16.0 | win32 | x64
gyp http GET https://nodejs.org/download/release/v22.16.0/node-v22.16.0-headers.tar.gz
gyp http 200 https://nodejs.org/download/release/v22.16.0/node-v22.16.0-headers.tar.gz
gyp http GET https://nodejs.org/download/release/v22.16.0/SHASUMS256.txt
gyp http GET https://nodejs.org/download/release/v22.16.0/win-x64/node.lib
gyp http 200 https://nodejs.org/download/release/v22.16.0/SHASUMS256.txt
gyp http 200 https://nodejs.org/download/release/v22.16.0/win-x64/node.lib
gyp info ok
PS F:\glfwExamle\glfwExample>
我们编写一个cmake文件来制定对应的文件和制定编译规则和包含等信息:
cmake_minimum_required(VERSION 3.15)
if(NOT WIN32)
message(FATAL_ERROR "capture_addon: Windows only.")
endif()
project(capture_addon LANGUAGES CXX)
set(NODE_ADDON_API_DIR "${CMAKE_CURRENT_SOURCE_DIR}/node_modules/node-addon-api")
if(NOT EXISTS "${NODE_ADDON_API_DIR}/napi.h")
set(NODE_ADDON_API_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../node_modules/node-addon-api")
endif()
# 必须与 ${CMAKE_JS_SRC}(win_delay_load_hook.cc)一起链接,否则 /DELAYLOAD:NODE.EXE 在 Electron 内无法正确解析,require(.node) 会崩溃。
# 参考 cmake-js README:add_library(... ${CMAKE_JS_SRC})
add_library(${PROJECT_NAME} SHARED
"addon.cpp"
"main_n.cpp"
"WasapiLoopback.cpp"
"audio_output_devices.cpp"
)
if(CMAKE_JS_SRC)
target_sources(${PROJECT_NAME} PRIVATE ${CMAKE_JS_SRC})
endif()
target_include_directories(${PROJECT_NAME} PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}"
"${NODE_ADDON_API_DIR}" # 提供 napi.h
"${CMAKE_JS_INC}" # 提供 node_api.h (由 cmake-js 自动注入)
)
target_compile_definitions(${PROJECT_NAME} PRIVATE
WGC_NATIVE_NODE_ADDON
NAPI_CPP_EXCEPTIONS
FMT_HEADER_ONLY
WIN32_LEAN_AND_MEAN
)
set_target_properties(${PROJECT_NAME} PROPERTIES
CXX_STANDARD 20
CXX_STANDARD_REQUIRED ON
PREFIX ""
SUFFIX ".node"
)
# 必须与 Node/Electron 一致使用动态 CRT(/MD)。cmake-js 默认 MultiThreaded(/MT) 时,require(.node) 常在 Electron 内立刻崩溃。
set_property(TARGET ${PROJECT_NAME} PROPERTY MSVC_RUNTIME_LIBRARY
"$<$<CONFIG:Debug>:MultiThreadedDebugDLL>$<$<CONFIG:Release>:MultiThreadedDLL>$<$<CONFIG:RelWithDebInfo>:MultiThreadedDLL>$<$<CONFIG:MinSizeRel>:MultiThreadedDLL>")
if(MSVC)
# /await 用于支持 C++/WinRT 的异步操作
target_compile_options(${PROJECT_NAME} PRIVATE /await /EHsc /permissive- /utf-8)
endif()
target_link_libraries(${PROJECT_NAME} PRIVATE
d3d11.lib
windowsapp.lib
delayimp.lib # 必须链接这个库来解决 __delayLoadHelper2 错误
mfplat.lib
mfuuid.lib
mfreadwrite.lib
avrt.lib
Mmdevapi.lib
"${CMAKE_JS_LIB}"
)
这里我们链接了d3d11.lib、windowsapp.lib(因为windows runtime 、WGC、以及CPP/winrt生成的api最终都会落在这类导入上,没有它链接阶段就会缺符号)、delayimp.lib(这是延迟加载的组件,提供了__delayLoadHelper2等,配合链接器/DELAYLOAD: ... cmake-js通常会带上对NODE的delay-load,如果没哟它就会出现缺delay load辅助函数一类的链接错误)、"${CMAKE_JS_LIB}"(cmake-js → Electron 自带的 node.lib,native addon必须和当前的Eletron的ABI对应的node.lib链接,否则就会链接错版本或者加载失败)
运行的时候electron会把node(同一套v8/libuv/N-API)嵌入electron.exe中,一般不会单独给个node.lib的文件。编译原生插件的时候,必须有一份和当前electron版本ABI一致的导入库才能在链接阶段解析napi_create_* 、 node_* 等符号。这份东西就叫做node.lib,属于windows下的导入库,来源一般就是cmake-js / node-gyp / @electron/rebuild 按照 runtime-verson去下载和缓存到本机:例如 ~\.cmake-js\electron-x64\v39.x.x\x64\node.lib
为什么需要delayimp.lib呢,在MSVC上,如果链接选项里使用了延迟加载(cmake-js的常见写法:/DELAYLOAD:NODE.EXE) ,意思就是一开始不把node里成千上万个符号都从磁盘解析完,而是用到哪个符号的时候再加载。
实现延迟加载需要运行时辅助函数(典型是 __delayLoadHelper2 等),它们写在 delayimp.lib(Delay Importer)里。
因此:只要你的 .node 是用「带 /DELAYLOAD」的方式链 node.lib 的,就必须再链 delayimp.lib,否则会报 「无法解析的外部符号 __delayLoadHelper2」 一类链接错误。
再结合 win_delay_load_hook.cc:在运行期让delay-load在electron环境下指向正确模块,而不是去磁盘上的node.exe。
它在延迟加载流程里「拦截」对 NODE.EXE 的解析,让符号实际从 当前 Electron 进程 里解析——这是 delayimp + /DELAYLOAD + hook 这一套配合起来,Electron 里 require(.node) 才稳定。
!tip 符号 可能有人第一次看到,有些不熟悉这个概念,符号symbol就是编译/链接阶段用来标识某段代码和数据的名字,链接器靠他在多个.obj .lib .dll之间对错号。一般分为函数符号比如说napi_create_string_utf8 以及 数据/全局符号:例如某些全局变量。 编译我们的addon.cpp来生产obj的时候里面会有未解析的符号引用,就比如我们调用的napi_get_undefine,此时还不知道地址,node.lib是导入库(里面主要都是桩信息,告诉链接器这些符号在实际运行的时候由哪个DLL/EXE提供)。链接成capture_addon.node之后,PE会记下这些要入到运行时再去找某个模块里寻找。延迟加载的思想和这个差不多,意思也是启动/刚加载.node的时候可以不先把每个符号都绑定完,用到的时候回去解析,和delayimp.lib、hook配合
最后,为什么需要一致使用动态 CRT /MD(对应 MSVC 选项 MultiThreadedDLL:CRT 在 vcruntime*.dll / ucrtbase.dll 等系统 DLL 里,进程内各模块共用一套堆与实现)。若误用 /MT(MultiThreaded:CRT 静态链进你的 .node,等于模块私有一套 CRT),跨 DLL 边界 malloc/free、FILE*、locale 等很容易变成未定义行为,Electron 里常见表现就是加载 .node 立刻崩或随机崩。
!hint Electron / 官方 Node Windows 发行版是按
/MD编的:主程序、node.dll、大量系统组件都在 同一套 UCRT + VCRuntime 上跑。
因为.node本质上是一个dll,加载进了进程之后,堆内存不能混用,如果一侧是A套CRT,一侧B套CRT,是未定义行为,常表现为访问冲突,N-API/V8 会在边界上分配、传递缓冲区,很容易暴毙。
!tldr CRT: C Runtime Library,即 C 语言运行时库。提供了malloc、free、new、delete 、printf、fopen、FILE* 等。 在Windows + MSVC环境下可以静态链进exe/dll (MT)。也可以跟随系统里的CRT DLL公用(/MD)。 locale是区域/本地化设置,包括字符分类,数字格式和日期格式和排序。
然后执行 npx cmake-js compile 来编译(或用上面的 pnpm run build:addon 一条龙),需要时用 x64 Native Tools 环境。
F:\glfwExamle\glfwExample\glfwExample>npx cmake-js compile -G "Ninja"
INFO TOOL Using Ninja generator, as specified from commandline.
INFO CMD BUILD
INFO RUN [
'cmake',
'--build',
'F:\\glfwExamle\\glfwExample\\glfwExample\\build',
'--config',
'Release'
]
ninja: error: loading 'build.ninja': The system cannot find the file specified.
INFO REP Build has been failed, trying to do a full rebuild.
INFO CMD CLEAN
INFO RUN [
'cmake',
'-E',
'remove_directory',
'F:\\glfwExamle\\glfwExample\\glfwExample\\build'
]
INFO CMD CONFIGURE
INFO RUN [
'cmake',
'F:\\glfwExamle\\glfwExample\\glfwExample',
'--no-warn-unused-cli',
'-G',
'Ninja',
'-DCMAKE_JS_VERSION=8.0.0',
'-DCMAKE_BUILD_TYPE=Release',
'-DCMAKE_RUNTIME_OUTPUT_DIRECTORY=F:\\glfwExamle\\glfwExample\\glfwExample\\build',
'-DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded$<$<CONFIG:Debug>:Debug>',
'-DCMAKE_JS_INC=C:\\Users\\20742\\.cmake-js\\node-x64\\v22.16.0\\include\\node',
'-DCMAKE_JS_SRC=F:/glfwExamle/glfwExample/glfwExample/node_modules/.pnpm/cmake-js@8.0.0/node_modules/cmake-js/lib/cpp/win_delay_load_hook.cc',
'-DNODE_RUNTIME=node',
'-DNODE_RUNTIMEVERSION=22.16.0',
'-DNODE_ARCH=x64',
'-DCMAKE_JS_LIB=C:\\Users\\20742\\.cmake-js\\node-x64\\v22.16.0\\win-x64\\node.lib',
'-DCMAKE_SHARED_LINKER_FLAGS=/DELAYLOAD:NODE.EXE'
]
Not searching for unused variables given on the command line.
-- The CXX compiler identification is MSVC 19.44.35220.0
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: C:/Program Files (x86)/Microsoft Visual Studio/2022/BuildTools/VC/Tools/MSVC/14.44.35207/bin/Hostx64/x64/cl.exe - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done (25.3s)
-- Generating done (2.7s)
-- Build files have been written to: F:/glfwExamle/glfwExample/glfwExample/build
INFO CMD BUILD
INFO RUN [
'cmake',
'--build',
'F:\\glfwExamle\\glfwExample\\glfwExample\\build',
'--config',
'Release'
]
[3/3] Linking CXX shared library capture_addon.node
编译成功,我们得到了一个.node文件,是node.js可以直接调用的二进制模块。可以快速做一个验证,你在同级目录新建js文件:
const addon = require('./build/capture_addon.node');
console.log('模块加载成功!');
try {
// 既然对象里有 start,直接试试它
const result = addon.start();
console.log('Start 结果:', result);
const size = addon.getSize();
console.log(`显示器尺寸: ${size.width} x ${size.height}`);
} catch (err) {
console.error('调用失败:', err);
}
输出:
(mmlab) PS F:\glfwExamle\glfwExample\glfwExample> node .\test.js
模块加载成功!
Start 结果: undefined
显示器尺寸: 1920 x 1080
(mmlab) PS F:\glfwExamle\glfwExample\glfwExample>
使用
测试通过之后,我们可以可以来真正使用这个原生模块了,由于Electron的主进程在nodejs中,渲染进程在chromium中,因此我们需要通过context bridge将原生模块能力桥接给前端。
为了让canvas可以渲染,我们需要一个能把c++的pixelBuffer传递给js的办法,我们可以使用Napi::Buffer,它可以允许JS直接访问C++的内存地址。
Napi::Value GetFrame(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
auto& buffer = g_capture->pixel_buffer();
auto& mutex = g_capture->buffer_mutex();
std::lock_guard<std::mutex> lock(mutex);
return Napi::Buffer<uint8_t>::Copy(env, buffer.data(), buffer.size());
}
在electron中,由于安全限制,不能直接在渲染进程require原生模块,因此我们最好的解决方法是写一个预加载脚本preload.js。
function loadAddon() {
if (addonTried) return addon
addonTried = true
if (process.env.SKIP_NATIVE === '1') return null
const p = join(__dirname, '../../native/wgc-addon/capture_addon.node')
if (!existsSync(p)) {
console.error('[preload] 未找到', p)
return null
}
try {
addon = require(p)
return addon
} catch (e) {
console.error('[preload] require .node 失败', e)
return null
}
}
function getAddon() {
return loadAddon()
}
const captureAPI = {
start: () => {
if (process.env.SKIP_NATIVE === '1') return Promise.resolve()
const a = getAddon()
if (!a) throw new Error('native addon not loaded')
return Promise.resolve(a.start())
},
stop: () => {
if (process.env.SKIP_NATIVE === '1') return undefined
const a = getAddon()
if (!a) return undefined
return a.stop()
},
getSize: () => {
if (process.env.SKIP_NATIVE === '1') return { width: 800, height: 600 }
const a = getAddon()
if (!a) return { width: 0, height: 0 }
return a.getSize()
},
getFrame: () => {
if (process.env.SKIP_NATIVE === '1') return null
const a = getAddon()
if (!a) return null
return a.getFrame()
},
/** outputPath:UTF-8,如 C:\\\\out\\\\cap.mp4 */
startRecording: (outputPath, config) => {
if (process.env.SKIP_NATIVE === '1') return Promise.resolve()
const a = getAddon()
if (!a) throw new Error('native addon not loaded')
return Promise.resolve(a.startRecording(outputPath, config))
},
stopRecording: () => {
if (process.env.SKIP_NATIVE === '1') return undefined
const a = getAddon()
if (!a) return undefined
return a.stopRecording()
},
isRecording: () => {
if (process.env.SKIP_NATIVE === '1') return false
const a = getAddon()
if (!a) return false
return a.isRecording()
},
/** 枚举播放设备;loopback 录制里可把返回的 id 填进 config.audioOutputDeviceId */
getAudioOutputDevices: () => {
if (process.env.SKIP_NATIVE === '1') return Promise.resolve([])
const a = getAddon()
if (!a) return Promise.resolve([])
return Promise.resolve(a.getAudioOutputDevices())
}
}
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('captureAPI', captureAPI)
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
} catch (error) {
console.error('[preload] expose failed:', error)
}
} else {
window.captureAPI = captureAPI
window.electron = electronAPI
window.api = api
}
我们使用了exposeInMainWorld,渲染进程的网页拿不到node,也不能访问preload里面的变量,必须用contextBridge.exposeInMainWorld把一组经过允许的函数/对象 代理到window上,才能让页面使用window.captureAPI等。
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
const screenCanvas = ref(null)
let timerId = null
onMounted(async () => {
const canvas = screenCanvas.value
const ctx = canvas.getContext('2d')
try {
await window.captureAPI.start()
} catch (e) {
console.error('capture start failed', e)
return
}
const size = await window.captureAPI.getSize()
if (!size || size.width === 0) {
console.error('无法获取显示器尺寸')
return
}
canvas.width = size.width
canvas.height = size.height
const imgData = ctx.createImageData(size.width, size.height)
// 原生已输出 RGBA;preload 调 getFrame。先约 15fps,后续可降采样 / SharedArrayBuffer
const tick = async () => {
try {
const buffer = await window.captureAPI.getFrame()
if (buffer) {
imgData.data.set(new Uint8Array(buffer))
ctx.putImageData(imgData, 0, 0)
}
} catch (e) {
console.error('getFrame', e)
}
}
timerId = window.setInterval(tick, 66)
})
onUnmounted(async () => {
if (timerId != null) window.clearInterval(timerId)
try {
await window.captureAPI.stop()
} catch (e) {
console.error('stop', e)
}
})
</script>
<template>
<div class="capture-container">
<canvas ref="screenCanvas" class="screen-render"></canvas>
</div>
</template>
<style scoped>
.capture-container {
width: 100%;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: #000;
}
.screen-render {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
</style>
之后就可以正确显示了!当前仓库里的 App.vue 在同一份思路上又加了:录制路径、startRecording/stopRecording、getAudioOutputDevices 下拉选播放设备、audioOutputDeviceId 传给原生等——仍以 capture/src/renderer/src/App.vue 为准,笔记里就不重复贴一整套表单了。