@napolab/texture-bridge-renderer 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Preview window preload script (CJS).
3
+ *
4
+ * Sets up the shared texture receiver and exposes a minimal API
5
+ * to the preview renderer via contextBridge.
6
+ */
7
+ const { contextBridge, ipcRenderer, sharedTexture } = require("electron");
8
+
9
+ let textureCallback = null;
10
+
11
+ try {
12
+ sharedTexture.setSharedTextureReceiver(async (data) => {
13
+ const imported = data.importedSharedTexture;
14
+ if (textureCallback && imported) {
15
+ textureCallback(imported);
16
+ }
17
+ });
18
+ } catch {
19
+ // sharedTexture may not be available in all contexts
20
+ }
21
+
22
+ contextBridge.exposeInMainWorld("electronAPI", {
23
+ platform: process.platform,
24
+ onTextureFrame: (callback) => {
25
+ textureCallback = callback;
26
+ },
27
+ previewReady: () => {
28
+ ipcRenderer.send("preview-ready");
29
+ },
30
+ });
@@ -0,0 +1,214 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Preview (GPU Zero-Copy)</title>
6
+ <style>
7
+ * { margin: 0; padding: 0; }
8
+ body {
9
+ background: #111;
10
+ display: flex;
11
+ flex-direction: column;
12
+ align-items: center;
13
+ justify-content: center;
14
+ height: 100vh;
15
+ font-family: -apple-system, BlinkMacSystemFont, sans-serif;
16
+ }
17
+ #info {
18
+ position: absolute;
19
+ top: 10px;
20
+ left: 10px;
21
+ color: #0f0;
22
+ font-family: 'SF Mono', Menlo, monospace;
23
+ font-size: 12px;
24
+ background: rgba(0, 0, 0, 0.7);
25
+ padding: 8px 12px;
26
+ border-radius: 4px;
27
+ z-index: 100;
28
+ }
29
+ canvas {
30
+ max-width: 100%;
31
+ max-height: calc(100vh - 20px);
32
+ object-fit: contain;
33
+ background: #000;
34
+ }
35
+ .error { color: #f44; }
36
+ </style>
37
+ </head>
38
+ <body>
39
+ <div id="info">Initializing WebGPU...</div>
40
+ <canvas id="preview"></canvas>
41
+
42
+ <script type="module">
43
+ const params = new URLSearchParams(location.search);
44
+ const W = parseInt(params.get('w'), 10) || 1920;
45
+ const H = parseInt(params.get('h'), 10) || 1080;
46
+
47
+ const canvas = document.getElementById('preview');
48
+ canvas.width = W;
49
+ canvas.height = H;
50
+
51
+ const infoEl = document.getElementById('info');
52
+
53
+ let device = null;
54
+ let context = null;
55
+ let pipeline = null;
56
+ let sampler = null;
57
+ let bindGroupLayout = null;
58
+
59
+ let frameCount = 0;
60
+ let lastTime = performance.now();
61
+ let fps = 0;
62
+
63
+ const shaderCode = `
64
+ struct VertexOutput {
65
+ @builtin(position) position: vec4f,
66
+ @location(0) texCoord: vec2f,
67
+ }
68
+
69
+ @vertex
70
+ fn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
71
+ var pos = array<vec2f, 3>(
72
+ vec2f(-1.0, -1.0),
73
+ vec2f( 3.0, -1.0),
74
+ vec2f(-1.0, 3.0)
75
+ );
76
+ var uv = array<vec2f, 3>(
77
+ vec2f(0.0, 1.0),
78
+ vec2f(2.0, 1.0),
79
+ vec2f(0.0, -1.0)
80
+ );
81
+
82
+ var output: VertexOutput;
83
+ output.position = vec4f(pos[vertexIndex], 0.0, 1.0);
84
+ output.texCoord = uv[vertexIndex];
85
+ return output;
86
+ }
87
+
88
+ @group(0) @binding(0) var externalTexture: texture_external;
89
+ @group(0) @binding(1) var texSampler: sampler;
90
+
91
+ @fragment
92
+ fn fragmentMain(@location(0) texCoord: vec2f) -> @location(0) vec4f {
93
+ return textureSampleBaseClampToEdge(externalTexture, texSampler, texCoord);
94
+ }
95
+ `;
96
+
97
+ async function initWebGPU() {
98
+ if (!navigator.gpu) {
99
+ throw new Error('WebGPU not supported');
100
+ }
101
+
102
+ const adapter = await navigator.gpu.requestAdapter();
103
+ if (!adapter) {
104
+ throw new Error('No GPU adapter found');
105
+ }
106
+
107
+ device = await adapter.requestDevice();
108
+ context = canvas.getContext('webgpu');
109
+
110
+ const format = navigator.gpu.getPreferredCanvasFormat();
111
+ context.configure({ device, format, alphaMode: 'opaque' });
112
+
113
+ const shaderModule = device.createShaderModule({ code: shaderCode });
114
+
115
+ sampler = device.createSampler({
116
+ magFilter: 'linear',
117
+ minFilter: 'linear',
118
+ });
119
+
120
+ bindGroupLayout = device.createBindGroupLayout({
121
+ entries: [
122
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, externalTexture: {} },
123
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
124
+ ],
125
+ });
126
+
127
+ pipeline = device.createRenderPipeline({
128
+ layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }),
129
+ vertex: { module: shaderModule, entryPoint: 'vertexMain' },
130
+ fragment: {
131
+ module: shaderModule,
132
+ entryPoint: 'fragmentMain',
133
+ targets: [{ format }],
134
+ },
135
+ primitive: { topology: 'triangle-list' },
136
+ });
137
+
138
+ infoEl.textContent = 'WebGPU ready, waiting for frames...';
139
+ }
140
+
141
+ function renderFrame(videoFrame) {
142
+ if (!device || !pipeline || !videoFrame) return;
143
+
144
+ try {
145
+ const externalTexture = device.importExternalTexture({ source: videoFrame });
146
+
147
+ const bindGroup = device.createBindGroup({
148
+ layout: bindGroupLayout,
149
+ entries: [
150
+ { binding: 0, resource: externalTexture },
151
+ { binding: 1, resource: sampler },
152
+ ],
153
+ });
154
+
155
+ const commandEncoder = device.createCommandEncoder();
156
+ const textureView = context.getCurrentTexture().createView();
157
+
158
+ const renderPass = commandEncoder.beginRenderPass({
159
+ colorAttachments: [{
160
+ view: textureView,
161
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
162
+ loadOp: 'clear',
163
+ storeOp: 'store',
164
+ }],
165
+ });
166
+
167
+ renderPass.setPipeline(pipeline);
168
+ renderPass.setBindGroup(0, bindGroup);
169
+ renderPass.draw(3);
170
+ renderPass.end();
171
+
172
+ device.queue.submit([commandEncoder.finish()]);
173
+
174
+ frameCount++;
175
+ const now = performance.now();
176
+ if (now - lastTime >= 1000) {
177
+ fps = frameCount * 1000 / (now - lastTime);
178
+ frameCount = 0;
179
+ lastTime = now;
180
+ }
181
+
182
+ infoEl.textContent = `GPU Zero-Copy | FPS: ${fps.toFixed(1)} | ${videoFrame.displayWidth}x${videoFrame.displayHeight}`;
183
+ } catch (err) {
184
+ console.error('[preview] render error:', err);
185
+ }
186
+ }
187
+
188
+ async function main() {
189
+ try {
190
+ await initWebGPU();
191
+
192
+ window.electronAPI.onTextureFrame((imported) => {
193
+ try {
194
+ const videoFrame = imported.getVideoFrame();
195
+ renderFrame(videoFrame);
196
+ videoFrame.close();
197
+ imported.release();
198
+ } catch (err) {
199
+ console.error('[preview] texture error:', err);
200
+ }
201
+ });
202
+
203
+ window.electronAPI.previewReady();
204
+ } catch (err) {
205
+ console.error('[preview] init error:', err);
206
+ infoEl.className = 'error';
207
+ infoEl.textContent = `WebGPU Error: ${err.message}`;
208
+ }
209
+ }
210
+
211
+ main();
212
+ </script>
213
+ </body>
214
+ </html>
@@ -0,0 +1,54 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+
3
+ //#region src/client/index.ts
4
+ /**
5
+ * Set up a canvas + OffscreenCanvas pipeline to a Web Worker.
6
+ *
7
+ * Creates (or reuses) a canvas, transfers it to the worker,
8
+ * and attaches a ResizeObserver to automatically send resize
9
+ * messages when the canvas dimensions change.
10
+ *
11
+ * This runs in the renderer process (browser context) only.
12
+ */
13
+ function createWorkerRenderer(options) {
14
+ const { worker, width, height } = options;
15
+ const canvas = options.canvas ?? document.createElement("canvas");
16
+ canvas.width = width;
17
+ canvas.height = height;
18
+ if (!options.canvas) (options.container ?? document.body).appendChild(canvas);
19
+ const offscreen = canvas.transferControlToOffscreen();
20
+ const initMsg = {
21
+ type: "init",
22
+ canvas: offscreen
23
+ };
24
+ worker.postMessage(initMsg, [offscreen]);
25
+ let lastW = width;
26
+ let lastH = height;
27
+ const observer = new ResizeObserver((entries) => {
28
+ for (const entry of entries) {
29
+ const { width: w, height: h } = entry.contentRect;
30
+ const rw = Math.round(w);
31
+ const rh = Math.round(h);
32
+ if (rw === lastW && rh === lastH) continue;
33
+ lastW = rw;
34
+ lastH = rh;
35
+ const msg = {
36
+ type: "resize",
37
+ width: rw,
38
+ height: rh
39
+ };
40
+ worker.postMessage(msg);
41
+ }
42
+ });
43
+ observer.observe(canvas);
44
+ return {
45
+ canvas,
46
+ worker,
47
+ dispose() {
48
+ observer.disconnect();
49
+ }
50
+ };
51
+ }
52
+
53
+ //#endregion
54
+ exports.createWorkerRenderer = createWorkerRenderer;
@@ -0,0 +1,35 @@
1
+ import { MainToWorkerMessage, WorkerMessage, WorkerToMainMessage } from "./worker-protocol.cjs";
2
+
3
+ //#region src/client/index.d.ts
4
+ interface WorkerRendererOptions {
5
+ /** An existing Worker instance */
6
+ worker: Worker;
7
+ /** Initial canvas width */
8
+ width: number;
9
+ /** Initial canvas height */
10
+ height: number;
11
+ /** Existing canvas element to use (created if omitted) */
12
+ canvas?: HTMLCanvasElement;
13
+ /** Container element to append the canvas to (defaults to document.body) */
14
+ container?: HTMLElement;
15
+ }
16
+ interface WorkerRendererHandle {
17
+ /** The canvas element */
18
+ canvas: HTMLCanvasElement;
19
+ /** The worker instance */
20
+ worker: Worker;
21
+ /** Stop observing resize and clean up */
22
+ dispose: () => void;
23
+ }
24
+ /**
25
+ * Set up a canvas + OffscreenCanvas pipeline to a Web Worker.
26
+ *
27
+ * Creates (or reuses) a canvas, transfers it to the worker,
28
+ * and attaches a ResizeObserver to automatically send resize
29
+ * messages when the canvas dimensions change.
30
+ *
31
+ * This runs in the renderer process (browser context) only.
32
+ */
33
+ declare function createWorkerRenderer(options: WorkerRendererOptions): WorkerRendererHandle;
34
+ //#endregion
35
+ export { type MainToWorkerMessage, type WorkerMessage, WorkerRendererHandle, WorkerRendererOptions, type WorkerToMainMessage, createWorkerRenderer };
@@ -0,0 +1,35 @@
1
+ import { MainToWorkerMessage, WorkerMessage, WorkerToMainMessage } from "./worker-protocol.mjs";
2
+
3
+ //#region src/client/index.d.ts
4
+ interface WorkerRendererOptions {
5
+ /** An existing Worker instance */
6
+ worker: Worker;
7
+ /** Initial canvas width */
8
+ width: number;
9
+ /** Initial canvas height */
10
+ height: number;
11
+ /** Existing canvas element to use (created if omitted) */
12
+ canvas?: HTMLCanvasElement;
13
+ /** Container element to append the canvas to (defaults to document.body) */
14
+ container?: HTMLElement;
15
+ }
16
+ interface WorkerRendererHandle {
17
+ /** The canvas element */
18
+ canvas: HTMLCanvasElement;
19
+ /** The worker instance */
20
+ worker: Worker;
21
+ /** Stop observing resize and clean up */
22
+ dispose: () => void;
23
+ }
24
+ /**
25
+ * Set up a canvas + OffscreenCanvas pipeline to a Web Worker.
26
+ *
27
+ * Creates (or reuses) a canvas, transfers it to the worker,
28
+ * and attaches a ResizeObserver to automatically send resize
29
+ * messages when the canvas dimensions change.
30
+ *
31
+ * This runs in the renderer process (browser context) only.
32
+ */
33
+ declare function createWorkerRenderer(options: WorkerRendererOptions): WorkerRendererHandle;
34
+ //#endregion
35
+ export { type MainToWorkerMessage, type WorkerMessage, WorkerRendererHandle, WorkerRendererOptions, type WorkerToMainMessage, createWorkerRenderer };
@@ -0,0 +1,52 @@
1
+ //#region src/client/index.ts
2
+ /**
3
+ * Set up a canvas + OffscreenCanvas pipeline to a Web Worker.
4
+ *
5
+ * Creates (or reuses) a canvas, transfers it to the worker,
6
+ * and attaches a ResizeObserver to automatically send resize
7
+ * messages when the canvas dimensions change.
8
+ *
9
+ * This runs in the renderer process (browser context) only.
10
+ */
11
+ function createWorkerRenderer(options) {
12
+ const { worker, width, height } = options;
13
+ const canvas = options.canvas ?? document.createElement("canvas");
14
+ canvas.width = width;
15
+ canvas.height = height;
16
+ if (!options.canvas) (options.container ?? document.body).appendChild(canvas);
17
+ const offscreen = canvas.transferControlToOffscreen();
18
+ const initMsg = {
19
+ type: "init",
20
+ canvas: offscreen
21
+ };
22
+ worker.postMessage(initMsg, [offscreen]);
23
+ let lastW = width;
24
+ let lastH = height;
25
+ const observer = new ResizeObserver((entries) => {
26
+ for (const entry of entries) {
27
+ const { width: w, height: h } = entry.contentRect;
28
+ const rw = Math.round(w);
29
+ const rh = Math.round(h);
30
+ if (rw === lastW && rh === lastH) continue;
31
+ lastW = rw;
32
+ lastH = rh;
33
+ const msg = {
34
+ type: "resize",
35
+ width: rw,
36
+ height: rh
37
+ };
38
+ worker.postMessage(msg);
39
+ }
40
+ });
41
+ observer.observe(canvas);
42
+ return {
43
+ canvas,
44
+ worker,
45
+ dispose() {
46
+ observer.disconnect();
47
+ }
48
+ };
49
+ }
50
+
51
+ //#endregion
52
+ export { createWorkerRenderer };
File without changes
@@ -0,0 +1,23 @@
1
+ //#region src/client/worker-protocol.d.ts
2
+ /** Messages sent from the main thread to the worker */
3
+ type MainToWorkerMessage = {
4
+ type: "init";
5
+ canvas: OffscreenCanvas;
6
+ } | {
7
+ type: "resize";
8
+ width: number;
9
+ height: number;
10
+ } | {
11
+ type: "dispose";
12
+ };
13
+ /** Messages sent from the worker to the main thread */
14
+ type WorkerToMainMessage = {
15
+ type: "ready";
16
+ } | {
17
+ type: "error";
18
+ message: string;
19
+ };
20
+ /** Union of all worker messages (convenience re-export) */
21
+ type WorkerMessage = MainToWorkerMessage;
22
+ //#endregion
23
+ export { MainToWorkerMessage, WorkerMessage, WorkerToMainMessage };
@@ -0,0 +1,23 @@
1
+ //#region src/client/worker-protocol.d.ts
2
+ /** Messages sent from the main thread to the worker */
3
+ type MainToWorkerMessage = {
4
+ type: "init";
5
+ canvas: OffscreenCanvas;
6
+ } | {
7
+ type: "resize";
8
+ width: number;
9
+ height: number;
10
+ } | {
11
+ type: "dispose";
12
+ };
13
+ /** Messages sent from the worker to the main thread */
14
+ type WorkerToMainMessage = {
15
+ type: "ready";
16
+ } | {
17
+ type: "error";
18
+ message: string;
19
+ };
20
+ /** Union of all worker messages (convenience re-export) */
21
+ type WorkerMessage = MainToWorkerMessage;
22
+ //#endregion
23
+ export { MainToWorkerMessage, WorkerMessage, WorkerToMainMessage };
@@ -0,0 +1 @@
1
+ export { };
package/dist/index.cjs ADDED
@@ -0,0 +1,241 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+ //#region \0rolldown/runtime.js
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
12
+ key = keys[i];
13
+ if (!__hasOwnProp.call(to, key) && key !== except) {
14
+ __defProp(to, key, {
15
+ get: ((k) => from[k]).bind(null, key),
16
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
17
+ });
18
+ }
19
+ }
20
+ }
21
+ return to;
22
+ };
23
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
24
+ value: mod,
25
+ enumerable: true
26
+ }) : target, mod));
27
+
28
+ //#endregion
29
+ let events = require("events");
30
+ let electron = require("electron");
31
+ let _napolab_texture_bridge_core = require("@napolab/texture-bridge-core");
32
+ let path = require("path");
33
+ path = __toESM(path);
34
+
35
+ //#region src/preview-manager.ts
36
+ /** Resolves asset paths relative to dist/assets/ regardless of CJS/ESM */
37
+ function assetPath(filename) {
38
+ return path.default.join(__dirname, "assets", filename);
39
+ }
40
+ var PreviewManager = class {
41
+ constructor(width, height, options) {
42
+ this.win = null;
43
+ this.ready = false;
44
+ this.onPreviewReady = (_event) => {
45
+ if (!this.win || this.win.isDestroyed()) return;
46
+ if (_event.sender.id !== this.win.webContents.id) return;
47
+ this.ready = true;
48
+ };
49
+ this.width = width;
50
+ this.height = height;
51
+ this.title = options?.title ?? "Preview (GPU Zero-Copy)";
52
+ }
53
+ get window() {
54
+ return this.win;
55
+ }
56
+ get isOpen() {
57
+ return this.win !== null && !this.win.isDestroyed();
58
+ }
59
+ open() {
60
+ if (this.isOpen) return;
61
+ this.ready = false;
62
+ this.win = new electron.BrowserWindow({
63
+ width: Math.round(this.width / 2),
64
+ height: Math.round(this.height / 2),
65
+ title: this.title,
66
+ webPreferences: {
67
+ contextIsolation: true,
68
+ nodeIntegration: false,
69
+ sandbox: false,
70
+ preload: assetPath("preview-preload.js")
71
+ }
72
+ });
73
+ electron.ipcMain.on("preview-ready", this.onPreviewReady);
74
+ this.win.loadFile(assetPath("preview.html"), { query: {
75
+ w: String(this.width),
76
+ h: String(this.height)
77
+ } });
78
+ this.win.on("closed", () => {
79
+ this.win = null;
80
+ this.ready = false;
81
+ electron.ipcMain.removeListener("preview-ready", this.onPreviewReady);
82
+ });
83
+ }
84
+ sendFrame(texture) {
85
+ if (!this.win || this.win.isDestroyed() || !this.ready) return;
86
+ try {
87
+ const imported = electron.sharedTexture.importSharedTexture({ textureInfo: texture.textureInfo });
88
+ if (!imported) return;
89
+ electron.sharedTexture.sendSharedTexture({
90
+ frame: this.win.webContents.mainFrame,
91
+ importedSharedTexture: imported
92
+ }).catch(() => {});
93
+ } catch {}
94
+ }
95
+ updateSize(width, height) {
96
+ this.width = width;
97
+ this.height = height;
98
+ if (!this.isOpen) return;
99
+ this.win.loadFile(assetPath("preview.html"), { query: {
100
+ w: String(width),
101
+ h: String(height)
102
+ } });
103
+ }
104
+ close() {
105
+ if (!this.win || this.win.isDestroyed()) return;
106
+ this.win.close();
107
+ this.win = null;
108
+ this.ready = false;
109
+ }
110
+ dispose() {
111
+ electron.ipcMain.removeListener("preview-ready", this.onPreviewReady);
112
+ this.close();
113
+ }
114
+ };
115
+
116
+ //#endregion
117
+ //#region src/fps-counter.ts
118
+ /** Simple FPS counter that reports once per second */
119
+ var FpsCounter = class {
120
+ constructor() {
121
+ this.count = 0;
122
+ this.lastTime = Date.now();
123
+ }
124
+ /** Call on every frame. Returns FPS when 1 second has elapsed, otherwise null. */
125
+ tick() {
126
+ this.count++;
127
+ const now = Date.now();
128
+ const elapsed = now - this.lastTime;
129
+ if (elapsed < 1e3) return null;
130
+ const fps = this.count * 1e3 / elapsed;
131
+ this.count = 0;
132
+ this.lastTime = now;
133
+ return fps;
134
+ }
135
+ };
136
+
137
+ //#endregion
138
+ //#region src/bridge.ts
139
+ var TextureBridgeImpl = class extends events.EventEmitter {
140
+ constructor(renderWindow, sender, previewManager, options) {
141
+ super();
142
+ this.fpsCounter = new FpsCounter();
143
+ this._disposed = false;
144
+ this._renderWindow = renderWindow;
145
+ this.sender = sender;
146
+ this.previewManager = previewManager;
147
+ this.options = options;
148
+ }
149
+ get renderWindow() {
150
+ return this._renderWindow;
151
+ }
152
+ get previewWindow() {
153
+ return this.previewManager?.window ?? null;
154
+ }
155
+ get isDisposed() {
156
+ return this._disposed;
157
+ }
158
+ openPreview() {
159
+ if (this._disposed) return;
160
+ if (!this.previewManager) this.previewManager = new PreviewManager(this.options.width, this.options.height, this.options.preview);
161
+ this.previewManager.open();
162
+ }
163
+ closePreview() {
164
+ this.previewManager?.close();
165
+ }
166
+ resize(width, height) {
167
+ if (this._disposed) return;
168
+ this.options = {
169
+ ...this.options,
170
+ width,
171
+ height
172
+ };
173
+ this._renderWindow.setSize(width, height);
174
+ this.sender.stop();
175
+ this.sender = new _napolab_texture_bridge_core.TextureSender(this.options.name, width, height);
176
+ this.previewManager?.updateSize(width, height);
177
+ this.emit("resize", width, height);
178
+ }
179
+ dispose() {
180
+ if (this._disposed) return;
181
+ this._disposed = true;
182
+ if (!this._renderWindow.isDestroyed()) this._renderWindow.close();
183
+ this.sender.stop();
184
+ this.previewManager?.dispose();
185
+ this.previewManager = null;
186
+ this.emit("disposed");
187
+ this.removeAllListeners();
188
+ }
189
+ };
190
+ /**
191
+ * Create a fully-wired texture bridge: offscreen window, native sender,
192
+ * optional preview, and FPS tracking.
193
+ *
194
+ * Must be called after `app.whenReady()`.
195
+ */
196
+ async function createTextureBridge(options) {
197
+ if (!electron.app.isReady()) throw new Error("createTextureBridge() must be called after app.whenReady()");
198
+ const { name, width, height, frameRate = 60, rendererUrl, preview, webPreferences } = options;
199
+ const renderWindow = new electron.BrowserWindow({
200
+ width,
201
+ height,
202
+ show: false,
203
+ webPreferences: {
204
+ contextIsolation: true,
205
+ nodeIntegration: false,
206
+ offscreen: { useSharedTexture: true },
207
+ ...webPreferences
208
+ }
209
+ });
210
+ const sender = new _napolab_texture_bridge_core.TextureSender(name, width, height);
211
+ let previewManager = null;
212
+ if (preview?.enabled !== false && preview) {
213
+ previewManager = new PreviewManager(width, height, preview);
214
+ previewManager.open();
215
+ }
216
+ const bridge = new TextureBridgeImpl(renderWindow, sender, previewManager, options);
217
+ renderWindow.webContents.on("paint", (event) => {
218
+ const texture = event.texture;
219
+ if (!texture) return;
220
+ try {
221
+ (0, _napolab_texture_bridge_core.sendTextureFromPaintEvent)(bridge.sender, texture.textureInfo);
222
+ bridge.previewManager?.sendFrame(texture);
223
+ } catch (err) {
224
+ const error = err instanceof Error ? err : new Error(String(err));
225
+ bridge.emit("error", error);
226
+ } finally {
227
+ texture.release?.();
228
+ }
229
+ const fps = bridge.fpsCounter.tick();
230
+ if (fps !== null) bridge.emit("fps", fps);
231
+ });
232
+ renderWindow.webContents.setFrameRate(frameRate);
233
+ if (rendererUrl.startsWith("http://") || rendererUrl.startsWith("https://")) await renderWindow.loadURL(rendererUrl);
234
+ else if (rendererUrl.startsWith("file://")) await renderWindow.loadURL(rendererUrl);
235
+ else await renderWindow.loadFile(rendererUrl);
236
+ bridge.emit("ready");
237
+ return bridge;
238
+ }
239
+
240
+ //#endregion
241
+ exports.createTextureBridge = createTextureBridge;
@@ -0,0 +1,66 @@
1
+ import { BrowserWindow } from "electron";
2
+
3
+ //#region src/types.d.ts
4
+ /** Options for the preview window */
5
+ interface PreviewOptions {
6
+ enabled?: boolean;
7
+ width?: number;
8
+ height?: number;
9
+ title?: string;
10
+ }
11
+ /** Options for createTextureBridge() */
12
+ interface TextureBridgeOptions {
13
+ /** Syphon/Spout sender name visible to VJ software */
14
+ name: string;
15
+ /** Texture width in pixels */
16
+ width: number;
17
+ /** Texture height in pixels */
18
+ height: number;
19
+ /** Target frame rate (default: 60) */
20
+ frameRate?: number;
21
+ /** URL to load in the offscreen renderer (file:// or http://) */
22
+ rendererUrl: string;
23
+ /** Preview window options */
24
+ preview?: PreviewOptions;
25
+ /** Additional webPreferences for the offscreen BrowserWindow */
26
+ webPreferences?: Electron.WebPreferences;
27
+ }
28
+ /** Events emitted by TextureBridge */
29
+ interface BridgeEvents {
30
+ fps: [fps: number];
31
+ ready: [];
32
+ error: [error: Error];
33
+ disposed: [];
34
+ resize: [width: number, height: number];
35
+ }
36
+ /** High-level texture bridge handle */
37
+ interface TextureBridge {
38
+ on<K extends keyof BridgeEvents>(event: K, listener: (...args: BridgeEvents[K]) => void): this;
39
+ off<K extends keyof BridgeEvents>(event: K, listener: (...args: BridgeEvents[K]) => void): this;
40
+ once<K extends keyof BridgeEvents>(event: K, listener: (...args: BridgeEvents[K]) => void): this;
41
+ /** Open the preview window (no-op if already open) */
42
+ openPreview(): void;
43
+ /** Close the preview window (no-op if already closed) */
44
+ closePreview(): void;
45
+ /** Resize all layers: offscreen window, sender, preview, and worker */
46
+ resize(width: number, height: number): void;
47
+ /** The offscreen BrowserWindow used for rendering */
48
+ readonly renderWindow: BrowserWindow;
49
+ /** The preview BrowserWindow, if open */
50
+ readonly previewWindow: BrowserWindow | null;
51
+ /** Whether the bridge has been disposed */
52
+ readonly isDisposed: boolean;
53
+ /** Tear down all resources */
54
+ dispose(): void;
55
+ }
56
+ //#endregion
57
+ //#region src/bridge.d.ts
58
+ /**
59
+ * Create a fully-wired texture bridge: offscreen window, native sender,
60
+ * optional preview, and FPS tracking.
61
+ *
62
+ * Must be called after `app.whenReady()`.
63
+ */
64
+ declare function createTextureBridge(options: TextureBridgeOptions): Promise<TextureBridge>;
65
+ //#endregion
66
+ export { type BridgeEvents, type PreviewOptions, type TextureBridge, type TextureBridgeOptions, createTextureBridge };
@@ -0,0 +1,66 @@
1
+ import { BrowserWindow } from "electron";
2
+
3
+ //#region src/types.d.ts
4
+ /** Options for the preview window */
5
+ interface PreviewOptions {
6
+ enabled?: boolean;
7
+ width?: number;
8
+ height?: number;
9
+ title?: string;
10
+ }
11
+ /** Options for createTextureBridge() */
12
+ interface TextureBridgeOptions {
13
+ /** Syphon/Spout sender name visible to VJ software */
14
+ name: string;
15
+ /** Texture width in pixels */
16
+ width: number;
17
+ /** Texture height in pixels */
18
+ height: number;
19
+ /** Target frame rate (default: 60) */
20
+ frameRate?: number;
21
+ /** URL to load in the offscreen renderer (file:// or http://) */
22
+ rendererUrl: string;
23
+ /** Preview window options */
24
+ preview?: PreviewOptions;
25
+ /** Additional webPreferences for the offscreen BrowserWindow */
26
+ webPreferences?: Electron.WebPreferences;
27
+ }
28
+ /** Events emitted by TextureBridge */
29
+ interface BridgeEvents {
30
+ fps: [fps: number];
31
+ ready: [];
32
+ error: [error: Error];
33
+ disposed: [];
34
+ resize: [width: number, height: number];
35
+ }
36
+ /** High-level texture bridge handle */
37
+ interface TextureBridge {
38
+ on<K extends keyof BridgeEvents>(event: K, listener: (...args: BridgeEvents[K]) => void): this;
39
+ off<K extends keyof BridgeEvents>(event: K, listener: (...args: BridgeEvents[K]) => void): this;
40
+ once<K extends keyof BridgeEvents>(event: K, listener: (...args: BridgeEvents[K]) => void): this;
41
+ /** Open the preview window (no-op if already open) */
42
+ openPreview(): void;
43
+ /** Close the preview window (no-op if already closed) */
44
+ closePreview(): void;
45
+ /** Resize all layers: offscreen window, sender, preview, and worker */
46
+ resize(width: number, height: number): void;
47
+ /** The offscreen BrowserWindow used for rendering */
48
+ readonly renderWindow: BrowserWindow;
49
+ /** The preview BrowserWindow, if open */
50
+ readonly previewWindow: BrowserWindow | null;
51
+ /** Whether the bridge has been disposed */
52
+ readonly isDisposed: boolean;
53
+ /** Tear down all resources */
54
+ dispose(): void;
55
+ }
56
+ //#endregion
57
+ //#region src/bridge.d.ts
58
+ /**
59
+ * Create a fully-wired texture bridge: offscreen window, native sender,
60
+ * optional preview, and FPS tracking.
61
+ *
62
+ * Must be called after `app.whenReady()`.
63
+ */
64
+ declare function createTextureBridge(options: TextureBridgeOptions): Promise<TextureBridge>;
65
+ //#endregion
66
+ export { type BridgeEvents, type PreviewOptions, type TextureBridge, type TextureBridgeOptions, createTextureBridge };
package/dist/index.mjs ADDED
@@ -0,0 +1,212 @@
1
+ import { EventEmitter } from "events";
2
+ import { BrowserWindow, app, ipcMain, sharedTexture } from "electron";
3
+ import { TextureSender, sendTextureFromPaintEvent } from "@napolab/texture-bridge-core";
4
+ import path from "path";
5
+
6
+ //#region src/preview-manager.ts
7
+ /** Resolves asset paths relative to dist/assets/ regardless of CJS/ESM */
8
+ function assetPath(filename) {
9
+ return path.join(__dirname, "assets", filename);
10
+ }
11
+ var PreviewManager = class {
12
+ constructor(width, height, options) {
13
+ this.win = null;
14
+ this.ready = false;
15
+ this.onPreviewReady = (_event) => {
16
+ if (!this.win || this.win.isDestroyed()) return;
17
+ if (_event.sender.id !== this.win.webContents.id) return;
18
+ this.ready = true;
19
+ };
20
+ this.width = width;
21
+ this.height = height;
22
+ this.title = options?.title ?? "Preview (GPU Zero-Copy)";
23
+ }
24
+ get window() {
25
+ return this.win;
26
+ }
27
+ get isOpen() {
28
+ return this.win !== null && !this.win.isDestroyed();
29
+ }
30
+ open() {
31
+ if (this.isOpen) return;
32
+ this.ready = false;
33
+ this.win = new BrowserWindow({
34
+ width: Math.round(this.width / 2),
35
+ height: Math.round(this.height / 2),
36
+ title: this.title,
37
+ webPreferences: {
38
+ contextIsolation: true,
39
+ nodeIntegration: false,
40
+ sandbox: false,
41
+ preload: assetPath("preview-preload.js")
42
+ }
43
+ });
44
+ ipcMain.on("preview-ready", this.onPreviewReady);
45
+ this.win.loadFile(assetPath("preview.html"), { query: {
46
+ w: String(this.width),
47
+ h: String(this.height)
48
+ } });
49
+ this.win.on("closed", () => {
50
+ this.win = null;
51
+ this.ready = false;
52
+ ipcMain.removeListener("preview-ready", this.onPreviewReady);
53
+ });
54
+ }
55
+ sendFrame(texture) {
56
+ if (!this.win || this.win.isDestroyed() || !this.ready) return;
57
+ try {
58
+ const imported = sharedTexture.importSharedTexture({ textureInfo: texture.textureInfo });
59
+ if (!imported) return;
60
+ sharedTexture.sendSharedTexture({
61
+ frame: this.win.webContents.mainFrame,
62
+ importedSharedTexture: imported
63
+ }).catch(() => {});
64
+ } catch {}
65
+ }
66
+ updateSize(width, height) {
67
+ this.width = width;
68
+ this.height = height;
69
+ if (!this.isOpen) return;
70
+ this.win.loadFile(assetPath("preview.html"), { query: {
71
+ w: String(width),
72
+ h: String(height)
73
+ } });
74
+ }
75
+ close() {
76
+ if (!this.win || this.win.isDestroyed()) return;
77
+ this.win.close();
78
+ this.win = null;
79
+ this.ready = false;
80
+ }
81
+ dispose() {
82
+ ipcMain.removeListener("preview-ready", this.onPreviewReady);
83
+ this.close();
84
+ }
85
+ };
86
+
87
+ //#endregion
88
+ //#region src/fps-counter.ts
89
+ /** Simple FPS counter that reports once per second */
90
+ var FpsCounter = class {
91
+ constructor() {
92
+ this.count = 0;
93
+ this.lastTime = Date.now();
94
+ }
95
+ /** Call on every frame. Returns FPS when 1 second has elapsed, otherwise null. */
96
+ tick() {
97
+ this.count++;
98
+ const now = Date.now();
99
+ const elapsed = now - this.lastTime;
100
+ if (elapsed < 1e3) return null;
101
+ const fps = this.count * 1e3 / elapsed;
102
+ this.count = 0;
103
+ this.lastTime = now;
104
+ return fps;
105
+ }
106
+ };
107
+
108
+ //#endregion
109
+ //#region src/bridge.ts
110
+ var TextureBridgeImpl = class extends EventEmitter {
111
+ constructor(renderWindow, sender, previewManager, options) {
112
+ super();
113
+ this.fpsCounter = new FpsCounter();
114
+ this._disposed = false;
115
+ this._renderWindow = renderWindow;
116
+ this.sender = sender;
117
+ this.previewManager = previewManager;
118
+ this.options = options;
119
+ }
120
+ get renderWindow() {
121
+ return this._renderWindow;
122
+ }
123
+ get previewWindow() {
124
+ return this.previewManager?.window ?? null;
125
+ }
126
+ get isDisposed() {
127
+ return this._disposed;
128
+ }
129
+ openPreview() {
130
+ if (this._disposed) return;
131
+ if (!this.previewManager) this.previewManager = new PreviewManager(this.options.width, this.options.height, this.options.preview);
132
+ this.previewManager.open();
133
+ }
134
+ closePreview() {
135
+ this.previewManager?.close();
136
+ }
137
+ resize(width, height) {
138
+ if (this._disposed) return;
139
+ this.options = {
140
+ ...this.options,
141
+ width,
142
+ height
143
+ };
144
+ this._renderWindow.setSize(width, height);
145
+ this.sender.stop();
146
+ this.sender = new TextureSender(this.options.name, width, height);
147
+ this.previewManager?.updateSize(width, height);
148
+ this.emit("resize", width, height);
149
+ }
150
+ dispose() {
151
+ if (this._disposed) return;
152
+ this._disposed = true;
153
+ if (!this._renderWindow.isDestroyed()) this._renderWindow.close();
154
+ this.sender.stop();
155
+ this.previewManager?.dispose();
156
+ this.previewManager = null;
157
+ this.emit("disposed");
158
+ this.removeAllListeners();
159
+ }
160
+ };
161
+ /**
162
+ * Create a fully-wired texture bridge: offscreen window, native sender,
163
+ * optional preview, and FPS tracking.
164
+ *
165
+ * Must be called after `app.whenReady()`.
166
+ */
167
+ async function createTextureBridge(options) {
168
+ if (!app.isReady()) throw new Error("createTextureBridge() must be called after app.whenReady()");
169
+ const { name, width, height, frameRate = 60, rendererUrl, preview, webPreferences } = options;
170
+ const renderWindow = new BrowserWindow({
171
+ width,
172
+ height,
173
+ show: false,
174
+ webPreferences: {
175
+ contextIsolation: true,
176
+ nodeIntegration: false,
177
+ offscreen: { useSharedTexture: true },
178
+ ...webPreferences
179
+ }
180
+ });
181
+ const sender = new TextureSender(name, width, height);
182
+ let previewManager = null;
183
+ if (preview?.enabled !== false && preview) {
184
+ previewManager = new PreviewManager(width, height, preview);
185
+ previewManager.open();
186
+ }
187
+ const bridge = new TextureBridgeImpl(renderWindow, sender, previewManager, options);
188
+ renderWindow.webContents.on("paint", (event) => {
189
+ const texture = event.texture;
190
+ if (!texture) return;
191
+ try {
192
+ sendTextureFromPaintEvent(bridge.sender, texture.textureInfo);
193
+ bridge.previewManager?.sendFrame(texture);
194
+ } catch (err) {
195
+ const error = err instanceof Error ? err : new Error(String(err));
196
+ bridge.emit("error", error);
197
+ } finally {
198
+ texture.release?.();
199
+ }
200
+ const fps = bridge.fpsCounter.tick();
201
+ if (fps !== null) bridge.emit("fps", fps);
202
+ });
203
+ renderWindow.webContents.setFrameRate(frameRate);
204
+ if (rendererUrl.startsWith("http://") || rendererUrl.startsWith("https://")) await renderWindow.loadURL(rendererUrl);
205
+ else if (rendererUrl.startsWith("file://")) await renderWindow.loadURL(rendererUrl);
206
+ else await renderWindow.loadFile(rendererUrl);
207
+ bridge.emit("ready");
208
+ return bridge;
209
+ }
210
+
211
+ //#endregion
212
+ export { createTextureBridge };
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@napolab/texture-bridge-renderer",
3
+ "version": "0.2.0",
4
+ "description": "High-level factory API for GPU texture bridge (BrowserWindow + Preview + Sender)",
5
+ "main": "./dist/index.cjs",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.cts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.mjs",
11
+ "require": "./dist/index.cjs"
12
+ },
13
+ "./client": {
14
+ "import": "./dist/client/index.mjs",
15
+ "require": "./dist/client/index.cjs"
16
+ },
17
+ "./client/worker-protocol": {
18
+ "import": "./dist/client/worker-protocol.mjs",
19
+ "require": "./dist/client/worker-protocol.cjs"
20
+ },
21
+ "./worker": {
22
+ "import": "./dist/client/worker-protocol.mjs",
23
+ "require": "./dist/client/worker-protocol.cjs"
24
+ },
25
+ "./package.json": "./package.json"
26
+ },
27
+ "dependencies": {
28
+ "@napolab/texture-bridge-core": "0.2.0"
29
+ },
30
+ "peerDependencies": {
31
+ "electron": ">=40.0.0"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "electron": {
35
+ "optional": true
36
+ }
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^22.0.0",
40
+ "tsdown": "^0.20.3",
41
+ "typescript": "^5.7.0"
42
+ },
43
+ "license": "MIT",
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "files": [
48
+ "dist"
49
+ ],
50
+ "scripts": {
51
+ "build": "tsdown src/index.ts src/client/index.ts src/client/worker-protocol.ts --format cjs,esm --dts && cp -r src/assets dist/assets",
52
+ "typecheck": "tsgo --noEmit"
53
+ }
54
+ }