@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.
- package/dist/assets/preview-preload.js +30 -0
- package/dist/assets/preview.html +214 -0
- package/dist/client/index.cjs +54 -0
- package/dist/client/index.d.cts +35 -0
- package/dist/client/index.d.mts +35 -0
- package/dist/client/index.mjs +52 -0
- package/dist/client/worker-protocol.cjs +0 -0
- package/dist/client/worker-protocol.d.cts +23 -0
- package/dist/client/worker-protocol.d.mts +23 -0
- package/dist/client/worker-protocol.mjs +1 -0
- package/dist/index.cjs +241 -0
- package/dist/index.d.cts +66 -0
- package/dist/index.d.mts +66 -0
- package/dist/index.mjs +212 -0
- package/package.json +54 -0
|
@@ -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;
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.mts
ADDED
|
@@ -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
|
+
}
|