linkshell-cli 0.1.16 → 0.2.1
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/README.md +3 -0
- package/dist/cli/src/index.js +5 -1
- package/dist/cli/src/index.js.map +1 -1
- package/dist/cli/src/runtime/bridge-session.d.ts +6 -0
- package/dist/cli/src/runtime/bridge-session.js +81 -0
- package/dist/cli/src/runtime/bridge-session.js.map +1 -1
- package/dist/cli/src/runtime/screen-fallback.d.ts +22 -0
- package/dist/cli/src/runtime/screen-fallback.js +202 -0
- package/dist/cli/src/runtime/screen-fallback.js.map +1 -0
- package/dist/cli/src/runtime/screen-share.d.ts +34 -0
- package/dist/cli/src/runtime/screen-share.js +243 -0
- package/dist/cli/src/runtime/screen-share.js.map +1 -0
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/shared-protocol/src/index.d.ts +162 -10
- package/dist/shared-protocol/src/index.js +38 -0
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +6 -3
- package/src/index.ts +4 -1
- package/src/runtime/bridge-session.ts +87 -0
- package/src/runtime/screen-fallback.ts +235 -0
- package/src/runtime/screen-share.ts +283 -0
- package/src/types/werift.d.ts +23 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { execSync, spawn } from "node:child_process";
|
|
2
|
+
import { readFileSync, unlinkSync, existsSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { platform } from "node:os";
|
|
6
|
+
import { createEnvelope, serializeEnvelope } from "@linkshell/protocol";
|
|
7
|
+
import type { Envelope } from "@linkshell/protocol";
|
|
8
|
+
|
|
9
|
+
export interface ScreenFallbackOptions {
|
|
10
|
+
fps: number;
|
|
11
|
+
quality: number;
|
|
12
|
+
scale: number;
|
|
13
|
+
sessionId: string;
|
|
14
|
+
onFrame: (envelope: Envelope) => void;
|
|
15
|
+
onStatus: (envelope: Envelope) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const CHUNK_SIZE = 48 * 1024; // 48KB per chunk, leave room for envelope overhead
|
|
19
|
+
const TMP_FILE = join(tmpdir(), `linkshell-screen-${process.pid}.jpg`);
|
|
20
|
+
|
|
21
|
+
export class ScreenFallback {
|
|
22
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
23
|
+
private frameId = 0;
|
|
24
|
+
private active = false;
|
|
25
|
+
private readonly options: ScreenFallbackOptions;
|
|
26
|
+
|
|
27
|
+
constructor(options: ScreenFallbackOptions) {
|
|
28
|
+
this.options = options;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
start(): void {
|
|
32
|
+
if (this.active) return;
|
|
33
|
+
this.active = true;
|
|
34
|
+
this.frameId = 0;
|
|
35
|
+
|
|
36
|
+
const interval = Math.max(50, Math.floor(1000 / this.options.fps));
|
|
37
|
+
|
|
38
|
+
this.options.onStatus(
|
|
39
|
+
createEnvelope({
|
|
40
|
+
type: "screen.status",
|
|
41
|
+
sessionId: this.options.sessionId,
|
|
42
|
+
payload: { active: true, mode: "fallback" as const },
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Capture first frame immediately
|
|
47
|
+
this.captureAndSend();
|
|
48
|
+
|
|
49
|
+
this.timer = setInterval(() => {
|
|
50
|
+
this.captureAndSend();
|
|
51
|
+
}, interval);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
stop(): void {
|
|
55
|
+
if (!this.active) return;
|
|
56
|
+
this.active = false;
|
|
57
|
+
if (this.timer) {
|
|
58
|
+
clearInterval(this.timer);
|
|
59
|
+
this.timer = undefined;
|
|
60
|
+
}
|
|
61
|
+
// Clean up temp file
|
|
62
|
+
try { if (existsSync(TMP_FILE)) unlinkSync(TMP_FILE); } catch {}
|
|
63
|
+
|
|
64
|
+
this.options.onStatus(
|
|
65
|
+
createEnvelope({
|
|
66
|
+
type: "screen.status",
|
|
67
|
+
sessionId: this.options.sessionId,
|
|
68
|
+
payload: { active: false, mode: "off" as const },
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private captureAndSend(): void {
|
|
74
|
+
if (!this.active) return;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const imgBuffer = this.captureScreen();
|
|
78
|
+
if (!imgBuffer || imgBuffer.length === 0) return;
|
|
79
|
+
|
|
80
|
+
const base64 = imgBuffer.toString("base64");
|
|
81
|
+
const frameId = this.frameId++;
|
|
82
|
+
|
|
83
|
+
// Get dimensions (approximate from JPEG header or use defaults)
|
|
84
|
+
const dims = this.getJpegDimensions(imgBuffer);
|
|
85
|
+
|
|
86
|
+
if (base64.length <= CHUNK_SIZE) {
|
|
87
|
+
// Single chunk
|
|
88
|
+
this.options.onFrame(
|
|
89
|
+
createEnvelope({
|
|
90
|
+
type: "screen.frame",
|
|
91
|
+
sessionId: this.options.sessionId,
|
|
92
|
+
payload: {
|
|
93
|
+
data: base64,
|
|
94
|
+
width: dims.width,
|
|
95
|
+
height: dims.height,
|
|
96
|
+
frameId,
|
|
97
|
+
chunkIndex: 0,
|
|
98
|
+
chunkTotal: 1,
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
} else {
|
|
103
|
+
// Split into chunks
|
|
104
|
+
const chunkTotal = Math.ceil(base64.length / CHUNK_SIZE);
|
|
105
|
+
for (let i = 0; i < chunkTotal; i++) {
|
|
106
|
+
if (!this.active) return;
|
|
107
|
+
const chunk = base64.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
|
|
108
|
+
this.options.onFrame(
|
|
109
|
+
createEnvelope({
|
|
110
|
+
type: "screen.frame",
|
|
111
|
+
sessionId: this.options.sessionId,
|
|
112
|
+
payload: {
|
|
113
|
+
data: chunk,
|
|
114
|
+
width: dims.width,
|
|
115
|
+
height: dims.height,
|
|
116
|
+
frameId,
|
|
117
|
+
chunkIndex: i,
|
|
118
|
+
chunkTotal,
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
// Silently skip failed frames
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private captureScreen(): Buffer | null {
|
|
130
|
+
const os = platform();
|
|
131
|
+
const q = this.options.quality;
|
|
132
|
+
const s = this.options.scale;
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
if (os === "darwin") {
|
|
136
|
+
// macOS: screencapture
|
|
137
|
+
execSync(
|
|
138
|
+
`screencapture -x -t jpg -C "${TMP_FILE}"`,
|
|
139
|
+
{ timeout: 3000, stdio: "pipe" },
|
|
140
|
+
);
|
|
141
|
+
if (!existsSync(TMP_FILE)) return null;
|
|
142
|
+
let buf = readFileSync(TMP_FILE);
|
|
143
|
+
|
|
144
|
+
// Resize if scale < 1 and sips is available
|
|
145
|
+
if (s < 1) {
|
|
146
|
+
try {
|
|
147
|
+
const w = Math.floor(this.getJpegDimensions(buf).width * s);
|
|
148
|
+
execSync(
|
|
149
|
+
`sips --resampleWidth ${w} -s formatOptions ${q} "${TMP_FILE}" --out "${TMP_FILE}"`,
|
|
150
|
+
{ timeout: 3000, stdio: "pipe" },
|
|
151
|
+
);
|
|
152
|
+
buf = readFileSync(TMP_FILE);
|
|
153
|
+
} catch {
|
|
154
|
+
// Use original size if resize fails
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return buf;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (os === "linux") {
|
|
161
|
+
// Linux: try import (ImageMagick) or scrot
|
|
162
|
+
try {
|
|
163
|
+
const resizeArg = s < 1 ? `-resize ${Math.floor(s * 100)}%` : "";
|
|
164
|
+
execSync(
|
|
165
|
+
`import -window root -quality ${q} ${resizeArg} jpeg:"${TMP_FILE}"`,
|
|
166
|
+
{ timeout: 3000, stdio: "pipe" },
|
|
167
|
+
);
|
|
168
|
+
} catch {
|
|
169
|
+
// Fallback to scrot
|
|
170
|
+
execSync(
|
|
171
|
+
`scrot -q ${q} "${TMP_FILE}"`,
|
|
172
|
+
{ timeout: 3000, stdio: "pipe" },
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
if (!existsSync(TMP_FILE)) return null;
|
|
176
|
+
return readFileSync(TMP_FILE);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Windows: use PowerShell
|
|
180
|
+
if (os === "win32") {
|
|
181
|
+
execSync(
|
|
182
|
+
`powershell -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Screen]::PrimaryScreen | ForEach-Object { $bmp = New-Object System.Drawing.Bitmap($_.Bounds.Width, $_.Bounds.Height); $g = [System.Drawing.Graphics]::FromImage($bmp); $g.CopyFromScreen($_.Bounds.Location, [System.Drawing.Point]::Empty, $_.Bounds.Size); $bmp.Save('${TMP_FILE.replace(/\\/g, "\\\\")}', [System.Drawing.Imaging.ImageFormat]::Jpeg) }"`,
|
|
183
|
+
{ timeout: 5000, stdio: "pipe" },
|
|
184
|
+
);
|
|
185
|
+
if (!existsSync(TMP_FILE)) return null;
|
|
186
|
+
return readFileSync(TMP_FILE);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return null;
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private getJpegDimensions(buf: Buffer): { width: number; height: number } {
|
|
196
|
+
// Parse JPEG SOF marker for dimensions
|
|
197
|
+
try {
|
|
198
|
+
let i = 2; // Skip SOI
|
|
199
|
+
while (i < buf.length - 8) {
|
|
200
|
+
if (buf[i] !== 0xff) break;
|
|
201
|
+
const marker = buf[i + 1]!;
|
|
202
|
+
// SOF0, SOF1, SOF2
|
|
203
|
+
if (marker >= 0xc0 && marker <= 0xc2) {
|
|
204
|
+
const height = buf.readUInt16BE(i + 5);
|
|
205
|
+
const width = buf.readUInt16BE(i + 7);
|
|
206
|
+
return { width, height };
|
|
207
|
+
}
|
|
208
|
+
const len = buf.readUInt16BE(i + 2);
|
|
209
|
+
i += 2 + len;
|
|
210
|
+
}
|
|
211
|
+
} catch {}
|
|
212
|
+
return { width: 1920, height: 1080 }; // Default fallback
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
static isAvailable(): boolean {
|
|
216
|
+
const os = platform();
|
|
217
|
+
try {
|
|
218
|
+
if (os === "darwin") {
|
|
219
|
+
execSync("which screencapture", { stdio: "pipe" });
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
if (os === "linux") {
|
|
223
|
+
try {
|
|
224
|
+
execSync("which import", { stdio: "pipe" });
|
|
225
|
+
return true;
|
|
226
|
+
} catch {
|
|
227
|
+
execSync("which scrot", { stdio: "pipe" });
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (os === "win32") return true; // PowerShell always available
|
|
232
|
+
} catch {}
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { spawn, execSync } from "node:child_process";
|
|
2
|
+
import type { ChildProcess } from "node:child_process";
|
|
3
|
+
import { platform } from "node:os";
|
|
4
|
+
import { createEnvelope } from "@linkshell/protocol";
|
|
5
|
+
import type { Envelope } from "@linkshell/protocol";
|
|
6
|
+
|
|
7
|
+
export interface ScreenShareOptions {
|
|
8
|
+
sessionId: string;
|
|
9
|
+
fps: number;
|
|
10
|
+
quality: number;
|
|
11
|
+
scale: number;
|
|
12
|
+
turnUrl?: string;
|
|
13
|
+
turnUser?: string;
|
|
14
|
+
turnPass?: string;
|
|
15
|
+
onSignal: (envelope: Envelope) => void;
|
|
16
|
+
onStatus: (envelope: Envelope) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* WebRTC screen sharing using werift (pure TS WebRTC) + ffmpeg.
|
|
21
|
+
*
|
|
22
|
+
* Flow:
|
|
23
|
+
* 1. ffmpeg captures screen → H.264 → writes to stdout as raw annexb
|
|
24
|
+
* 2. We read H.264 NAL units and push them into werift video track
|
|
25
|
+
* 3. werift handles ICE/DTLS/SRTP and sends to remote peer
|
|
26
|
+
* 4. Signaling (SDP offer/answer, ICE candidates) goes through existing WebSocket
|
|
27
|
+
*/
|
|
28
|
+
export class ScreenShare {
|
|
29
|
+
private pc: any; // werift.RTCPeerConnection
|
|
30
|
+
private ffmpeg: ChildProcess | undefined;
|
|
31
|
+
private active = false;
|
|
32
|
+
private readonly options: ScreenShareOptions;
|
|
33
|
+
|
|
34
|
+
constructor(options: ScreenShareOptions) {
|
|
35
|
+
this.options = options;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
static isAvailable(): boolean {
|
|
39
|
+
// Check if werift can be imported and ffmpeg exists
|
|
40
|
+
try {
|
|
41
|
+
execSync("which ffmpeg", { stdio: "pipe" });
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
require.resolve("werift");
|
|
47
|
+
return true;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async start(): Promise<void> {
|
|
54
|
+
if (this.active) return;
|
|
55
|
+
this.active = true;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const werift = await import("werift");
|
|
59
|
+
|
|
60
|
+
// ICE servers
|
|
61
|
+
const iceServers: any[] = [
|
|
62
|
+
{ urls: "stun:stun.l.google.com:19302" },
|
|
63
|
+
];
|
|
64
|
+
if (this.options.turnUrl) {
|
|
65
|
+
iceServers.push({
|
|
66
|
+
urls: this.options.turnUrl,
|
|
67
|
+
username: this.options.turnUser ?? "",
|
|
68
|
+
credential: this.options.turnPass ?? "",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this.pc = new werift.RTCPeerConnection({
|
|
73
|
+
iceServers,
|
|
74
|
+
bundlePolicy: "max-bundle",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Create video track
|
|
78
|
+
const videoTrack = new werift.MediaStreamTrack({ kind: "video" });
|
|
79
|
+
this.pc.addTrack(videoTrack);
|
|
80
|
+
|
|
81
|
+
// ICE candidate → send to remote
|
|
82
|
+
this.pc.onIceCandidate.subscribe((candidate: any) => {
|
|
83
|
+
if (candidate) {
|
|
84
|
+
this.options.onSignal(
|
|
85
|
+
createEnvelope({
|
|
86
|
+
type: "screen.ice",
|
|
87
|
+
sessionId: this.options.sessionId,
|
|
88
|
+
payload: {
|
|
89
|
+
candidate: candidate.candidate,
|
|
90
|
+
sdpMid: candidate.sdpMid ?? null,
|
|
91
|
+
sdpMLineIndex: candidate.sdpMLineIndex ?? null,
|
|
92
|
+
},
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Connection state changes
|
|
99
|
+
this.pc.connectionStateChange.subscribe((state: string) => {
|
|
100
|
+
if (state === "connected") {
|
|
101
|
+
this.options.onStatus(
|
|
102
|
+
createEnvelope({
|
|
103
|
+
type: "screen.status",
|
|
104
|
+
sessionId: this.options.sessionId,
|
|
105
|
+
payload: { active: true, mode: "webrtc" as const },
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
} else if (state === "failed" || state === "disconnected" || state === "closed") {
|
|
109
|
+
this.options.onStatus(
|
|
110
|
+
createEnvelope({
|
|
111
|
+
type: "screen.status",
|
|
112
|
+
sessionId: this.options.sessionId,
|
|
113
|
+
payload: { active: false, mode: "off" as const, error: `WebRTC ${state}` },
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Create offer
|
|
120
|
+
const offer = await this.pc.createOffer();
|
|
121
|
+
await this.pc.setLocalDescription(offer);
|
|
122
|
+
|
|
123
|
+
this.options.onSignal(
|
|
124
|
+
createEnvelope({
|
|
125
|
+
type: "screen.offer",
|
|
126
|
+
sessionId: this.options.sessionId,
|
|
127
|
+
payload: { sdp: offer.sdp },
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// Start ffmpeg capture (will begin sending once ICE connects)
|
|
132
|
+
this.startFfmpeg(videoTrack);
|
|
133
|
+
|
|
134
|
+
} catch (err) {
|
|
135
|
+
this.options.onStatus(
|
|
136
|
+
createEnvelope({
|
|
137
|
+
type: "screen.status",
|
|
138
|
+
sessionId: this.options.sessionId,
|
|
139
|
+
payload: {
|
|
140
|
+
active: false,
|
|
141
|
+
mode: "off" as const,
|
|
142
|
+
error: `WebRTC init failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
this.active = false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async handleAnswer(sdp: string): Promise<void> {
|
|
151
|
+
if (!this.pc) return;
|
|
152
|
+
try {
|
|
153
|
+
const werift = await import("werift");
|
|
154
|
+
await this.pc.setRemoteDescription(
|
|
155
|
+
new werift.RTCSessionDescription(sdp, "answer"),
|
|
156
|
+
);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
process.stderr.write(`[screen-share] failed to set answer: ${err}\n`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async handleIceCandidate(candidate: string, sdpMid?: string | null, sdpMLineIndex?: number | null): Promise<void> {
|
|
163
|
+
if (!this.pc) return;
|
|
164
|
+
try {
|
|
165
|
+
const werift = await import("werift");
|
|
166
|
+
await this.pc.addIceCandidate(
|
|
167
|
+
new werift.RTCIceCandidate({ candidate, sdpMid: sdpMid ?? undefined, sdpMLineIndex: sdpMLineIndex ?? undefined }),
|
|
168
|
+
);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
process.stderr.write(`[screen-share] failed to add ICE candidate: ${err}\n`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
stop(): void {
|
|
175
|
+
this.active = false;
|
|
176
|
+
if (this.ffmpeg) {
|
|
177
|
+
this.ffmpeg.kill("SIGTERM");
|
|
178
|
+
this.ffmpeg = undefined;
|
|
179
|
+
}
|
|
180
|
+
if (this.pc) {
|
|
181
|
+
try { this.pc.close(); } catch {}
|
|
182
|
+
this.pc = undefined;
|
|
183
|
+
}
|
|
184
|
+
this.options.onStatus(
|
|
185
|
+
createEnvelope({
|
|
186
|
+
type: "screen.status",
|
|
187
|
+
sessionId: this.options.sessionId,
|
|
188
|
+
payload: { active: false, mode: "off" as const },
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private startFfmpeg(videoTrack: any): void {
|
|
194
|
+
const os = platform();
|
|
195
|
+
const fps = this.options.fps;
|
|
196
|
+
const scale = this.options.scale;
|
|
197
|
+
|
|
198
|
+
let inputArgs: string[];
|
|
199
|
+
if (os === "darwin") {
|
|
200
|
+
// macOS: AVFoundation screen capture
|
|
201
|
+
inputArgs = ["-f", "avfoundation", "-framerate", String(fps), "-i", "1:none"];
|
|
202
|
+
} else if (os === "linux") {
|
|
203
|
+
// Linux: X11 screen grab
|
|
204
|
+
inputArgs = ["-f", "x11grab", "-framerate", String(fps), "-i", ":0"];
|
|
205
|
+
} else {
|
|
206
|
+
// Windows: GDI screen grab
|
|
207
|
+
inputArgs = ["-f", "gdigrab", "-framerate", String(fps), "-i", "desktop"];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const scaleFilter = scale < 1 ? ["-vf", `scale=iw*${scale}:ih*${scale}`] : [];
|
|
211
|
+
|
|
212
|
+
this.ffmpeg = spawn("ffmpeg", [
|
|
213
|
+
...inputArgs,
|
|
214
|
+
...scaleFilter,
|
|
215
|
+
"-c:v", "libx264",
|
|
216
|
+
"-preset", "ultrafast",
|
|
217
|
+
"-tune", "zerolatency",
|
|
218
|
+
"-profile:v", "baseline",
|
|
219
|
+
"-level", "3.1",
|
|
220
|
+
"-pix_fmt", "yuv420p",
|
|
221
|
+
"-g", String(fps * 2), // keyframe every 2 seconds
|
|
222
|
+
"-b:v", "1500k",
|
|
223
|
+
"-maxrate", "2000k",
|
|
224
|
+
"-bufsize", "4000k",
|
|
225
|
+
"-f", "h264",
|
|
226
|
+
"-an", // no audio
|
|
227
|
+
"pipe:1",
|
|
228
|
+
], {
|
|
229
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Read H.264 NAL units from stdout and push to video track
|
|
233
|
+
let nalBuffer = Buffer.alloc(0);
|
|
234
|
+
|
|
235
|
+
this.ffmpeg.stdout?.on("data", (chunk: Buffer) => {
|
|
236
|
+
if (!this.active) return;
|
|
237
|
+
|
|
238
|
+
nalBuffer = Buffer.concat([nalBuffer, chunk]);
|
|
239
|
+
|
|
240
|
+
// Split on NAL unit start codes (0x00000001 or 0x000001)
|
|
241
|
+
let offset = 0;
|
|
242
|
+
while (offset < nalBuffer.length - 4) {
|
|
243
|
+
let startCodeLen = 0;
|
|
244
|
+
if (nalBuffer[offset] === 0 && nalBuffer[offset + 1] === 0) {
|
|
245
|
+
if (nalBuffer[offset + 2] === 0 && nalBuffer[offset + 3] === 1) {
|
|
246
|
+
startCodeLen = 4;
|
|
247
|
+
} else if (nalBuffer[offset + 2] === 1) {
|
|
248
|
+
startCodeLen = 3;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (startCodeLen > 0 && offset > 0) {
|
|
253
|
+
// Found a NAL unit boundary
|
|
254
|
+
const nalUnit = nalBuffer.subarray(0, offset);
|
|
255
|
+
try {
|
|
256
|
+
videoTrack.writeRtp(nalUnit);
|
|
257
|
+
} catch {
|
|
258
|
+
// Track might not be ready yet
|
|
259
|
+
}
|
|
260
|
+
nalBuffer = nalBuffer.subarray(offset);
|
|
261
|
+
offset = 0;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
offset++;
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
this.ffmpeg.stderr?.on("data", (data: Buffer) => {
|
|
269
|
+
// ffmpeg logs to stderr, ignore unless debugging
|
|
270
|
+
const msg = data.toString();
|
|
271
|
+
if (msg.includes("Error") || msg.includes("error")) {
|
|
272
|
+
process.stderr.write(`[screen-share:ffmpeg] ${msg}\n`);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
this.ffmpeg.on("exit", (code) => {
|
|
277
|
+
if (this.active) {
|
|
278
|
+
process.stderr.write(`[screen-share] ffmpeg exited with code ${code}\n`);
|
|
279
|
+
this.stop();
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
declare module "werift" {
|
|
2
|
+
export class RTCPeerConnection {
|
|
3
|
+
constructor(config?: any);
|
|
4
|
+
onIceCandidate: { subscribe: (cb: (candidate: any) => void) => void };
|
|
5
|
+
connectionStateChange: { subscribe: (cb: (state: string) => void) => void };
|
|
6
|
+
addTrack(track: MediaStreamTrack): void;
|
|
7
|
+
createOffer(): Promise<{ sdp: string; type: string }>;
|
|
8
|
+
setLocalDescription(desc: any): Promise<void>;
|
|
9
|
+
setRemoteDescription(desc: any): Promise<void>;
|
|
10
|
+
addIceCandidate(candidate: any): Promise<void>;
|
|
11
|
+
close(): void;
|
|
12
|
+
}
|
|
13
|
+
export class RTCSessionDescription {
|
|
14
|
+
constructor(sdp: string, type: string);
|
|
15
|
+
}
|
|
16
|
+
export class RTCIceCandidate {
|
|
17
|
+
constructor(init: { candidate: string; sdpMid?: string; sdpMLineIndex?: number });
|
|
18
|
+
}
|
|
19
|
+
export class MediaStreamTrack {
|
|
20
|
+
constructor(init: { kind: string });
|
|
21
|
+
writeRtp(data: Buffer): void;
|
|
22
|
+
}
|
|
23
|
+
}
|