@sanohiro/casty 1.1.2 → 1.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.ja.md +7 -0
- package/README.md +7 -0
- package/bin/casty +13 -1
- package/bin/casty.js +6 -2
- package/lib/browser.js +77 -31
- package/lib/config.js +1 -1
- package/lib/input.js +16 -1
- package/lib/media.js +202 -0
- package/package.json +1 -1
package/README.ja.md
CHANGED
|
@@ -31,6 +31,12 @@ casty は w3m や lynx のようなテキストブラウザではありません
|
|
|
31
31
|
|
|
32
32
|
SSH でヘッドレスサーバーに入っていて Web ページを確認したいとき、普通は `curl` か `lynx` か X11 転送しかない。casty ならターミナルを離れずに本物のブラウザが使えます。X11 も VNC も Wayland もいらない。Kitty 対応ターミナルさえあれば OK。
|
|
33
33
|
|
|
34
|
+
### Google Meet でカメラ&マイク(実験的)
|
|
35
|
+
|
|
36
|
+

|
|
37
|
+
|
|
38
|
+
ffmpeg 経由でカメラとマイクをキャプチャし、Google Meet や Zoom 等の WebRTC サイトで使えます。`ffmpeg` のインストールが必要です。映像はデバイスから直接取得するため、背景エフェクトは使えません。有効にするには[設定](#設定)を参照。
|
|
39
|
+
|
|
34
40
|
## インストール
|
|
35
41
|
|
|
36
42
|
```bash
|
|
@@ -124,6 +130,7 @@ casty # ホームページを開く
|
|
|
124
130
|
| `transport` | 画像転送方式: `auto`, `file`, `inline` | `auto` (bcon/kitty→file、他→inline) |
|
|
125
131
|
| `format` | キャプチャ形式: `auto`, `png`, `jpeg` | `auto` (file→jpeg adaptive、inline→png) |
|
|
126
132
|
| `mouseMode` | `1002` (ボタンイベント) or `1003` (全イベント) | 自動 (Ghostty→1003、他→1002) |
|
|
133
|
+
| `media` | WebRTC 用カメラ/マイク有効化(実験的、`ffmpeg` 必要) | `false` |
|
|
127
134
|
|
|
128
135
|
## 比較
|
|
129
136
|
|
package/README.md
CHANGED
|
@@ -31,6 +31,12 @@ Since it's real Chrome, JavaScript, CSS, Canvas, and WebGL all work. Google logi
|
|
|
31
31
|
|
|
32
32
|
If you're working over SSH on a headless server and need to check a web page, your options are usually `curl`, `lynx`, or forwarding X11. casty gives you an actual browser without leaving the terminal. No X11, no VNC, no Wayland — just a Kitty-compatible terminal.
|
|
33
33
|
|
|
34
|
+
### Google Meet with camera & mic (experimental)
|
|
35
|
+
|
|
36
|
+

|
|
37
|
+
|
|
38
|
+
Camera and microphone can be streamed to WebRTC sites like Google Meet, Zoom, etc. via ffmpeg. Requires `ffmpeg` installed. Background effects are not available since the video is captured directly from the device. See [Configuration](#configuration) to enable.
|
|
39
|
+
|
|
34
40
|
## Installation
|
|
35
41
|
|
|
36
42
|
```bash
|
|
@@ -124,6 +130,7 @@ Create `~/.casty/bookmarks.json`:
|
|
|
124
130
|
| `transport` | Image transfer: `auto`, `file`, `inline` | `auto` (bcon/kitty→file, others→inline) |
|
|
125
131
|
| `format` | Capture format: `auto`, `png`, `jpeg` | `auto` (file→jpeg adaptive, inline→png) |
|
|
126
132
|
| `mouseMode` | `1002` (button-event) or `1003` (any-event) | Auto (Ghostty→1003, others→1002) |
|
|
133
|
+
| `media` | Enable camera/mic for WebRTC (experimental, requires `ffmpeg`) | `false` |
|
|
127
134
|
|
|
128
135
|
## Comparison
|
|
129
136
|
|
package/bin/casty
CHANGED
|
@@ -115,7 +115,12 @@ install_chromium() {
|
|
|
115
115
|
mkdir -p "$BROWSERS_DIR"
|
|
116
116
|
# zip 内は chrome-headless-shell-<platform>/ ディレクトリに入っている
|
|
117
117
|
local tmp_dir=$(mktemp -d /tmp/casty-extract-XXXXXX)
|
|
118
|
-
unzip -q "$tmp_zip" -d "$tmp_dir" 2>/dev/null
|
|
118
|
+
if ! unzip -q "$tmp_zip" -d "$tmp_dir" 2>/dev/null; then
|
|
119
|
+
rm -f "$tmp_zip"
|
|
120
|
+
rm -rf "$tmp_dir"
|
|
121
|
+
echo "casty: Extraction failed (disk full?)" >&2
|
|
122
|
+
return 1
|
|
123
|
+
fi
|
|
119
124
|
rm -f "$tmp_zip"
|
|
120
125
|
|
|
121
126
|
# 展開されたディレクトリを見つけてリネーム
|
|
@@ -130,6 +135,13 @@ install_chromium() {
|
|
|
130
135
|
rm -rf "$tmp_dir"
|
|
131
136
|
chmod +x "$dest_dir/chrome-headless-shell"
|
|
132
137
|
|
|
138
|
+
# Verify binary is functional (catches truncated/corrupt downloads)
|
|
139
|
+
if ! "$dest_dir/chrome-headless-shell" --version &>/dev/null; then
|
|
140
|
+
echo "casty: Downloaded headless-shell is broken, removing..." >&2
|
|
141
|
+
rm -rf "$dest_dir"
|
|
142
|
+
return 1
|
|
143
|
+
fi
|
|
144
|
+
|
|
133
145
|
touch "$STAMP"
|
|
134
146
|
echo "casty: Installed Chrome Headless Shell ${version}" >&2
|
|
135
147
|
}
|
package/bin/casty.js
CHANGED
|
@@ -60,6 +60,7 @@ import { sendFrame, resetFrameCache, clearScreen, hideCursor, showCursor, cleanu
|
|
|
60
60
|
import { enableMouse, disableMouse, startInputHandling } from '../lib/input.js';
|
|
61
61
|
import { loadKeyBindings } from '../lib/keys.js';
|
|
62
62
|
import { loadConfig } from '../lib/config.js';
|
|
63
|
+
import { startMedia } from '../lib/media.js';
|
|
63
64
|
|
|
64
65
|
const config = loadConfig();
|
|
65
66
|
const bindings = loadKeyBindings();
|
|
@@ -148,11 +149,13 @@ async function getTermInfo({ keepAlive = false } = {}) {
|
|
|
148
149
|
}
|
|
149
150
|
|
|
150
151
|
async function main() {
|
|
151
|
-
// Phase 1: Launch Chrome
|
|
152
|
+
// Phase 1: Launch Chrome, get terminal info, and start media in parallel
|
|
152
153
|
// getTermInfo() must complete fully (prevent CSI 14t response leak)
|
|
153
154
|
const browserP = startBrowser();
|
|
155
|
+
const mediaP = config.media ? startMedia(config) : null;
|
|
154
156
|
const term = await getTermInfo();
|
|
155
157
|
const browser = await browserP;
|
|
158
|
+
const media = mediaP ? await mediaP : null;
|
|
156
159
|
|
|
157
160
|
// Reserve line 1 for URL bar, use the rest for browser display
|
|
158
161
|
const barHeight = term.cellHeight;
|
|
@@ -160,7 +163,7 @@ async function main() {
|
|
|
160
163
|
setDisplaySize(term.cols, term.rows - 1);
|
|
161
164
|
|
|
162
165
|
// Phase 2: CDP connection + page setup
|
|
163
|
-
const { client, cssWidth, cssHeight } = await setupPage(browser, { ...term, height: viewHeight });
|
|
166
|
+
const { client, cssWidth, cssHeight } = await setupPage(browser, { ...term, height: viewHeight, mediaPort: media?.port || 0 });
|
|
164
167
|
const chromeProcess = browser.proc;
|
|
165
168
|
|
|
166
169
|
// Log WebSocket errors to stderr (prevent unhandled crash)
|
|
@@ -237,6 +240,7 @@ async function main() {
|
|
|
237
240
|
} catch {}
|
|
238
241
|
client.close();
|
|
239
242
|
chromeProcess.kill();
|
|
243
|
+
media?.cleanup();
|
|
240
244
|
disableMouse();
|
|
241
245
|
showCursor();
|
|
242
246
|
try { process.stdin.setRawMode(false); } catch {}
|
package/lib/browser.js
CHANGED
|
@@ -33,7 +33,7 @@ const CAPTURE_STUCK_RESET = 5000; // Reset stuck capturing flag (ms)
|
|
|
33
33
|
|
|
34
34
|
// Build stealth script with locale-dependent language settings
|
|
35
35
|
// lang: primary language (e.g. "ja", "en-US")
|
|
36
|
-
function buildStealthScript(lang, {
|
|
36
|
+
function buildStealthScript(lang, { media = false, mediaPort = 0 } = {}) {
|
|
37
37
|
// Build Accept-Language style list: primary, then en-US/en fallbacks
|
|
38
38
|
const languages = [lang];
|
|
39
39
|
if (lang !== 'en-US' && lang !== 'en') {
|
|
@@ -93,7 +93,7 @@ navigator.permissions.query = (params) => {
|
|
|
93
93
|
if (params.name === 'notifications') {
|
|
94
94
|
return Promise.resolve({ state: Notification.permission });
|
|
95
95
|
}
|
|
96
|
-
${
|
|
96
|
+
${media ? ` if (params.name === 'camera' || params.name === 'microphone') {
|
|
97
97
|
return Promise.resolve({ state: 'granted' });
|
|
98
98
|
}` : ''}
|
|
99
99
|
return origQuery(params);
|
|
@@ -115,35 +115,81 @@ if (!navigator.connection) {
|
|
|
115
115
|
});
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
${
|
|
119
|
-
// getUserMedia
|
|
118
|
+
${media && mediaPort ? `
|
|
119
|
+
// getUserMedia with real device capture via ffmpeg WebSocket relay
|
|
120
|
+
if (!navigator.mediaDevices) navigator.mediaDevices = {};
|
|
120
121
|
if (navigator.mediaDevices) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
canvas
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
122
|
+
navigator.mediaDevices.getUserMedia = (constraints) => {
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
const tracks = [];
|
|
125
|
+
const ws = new WebSocket('ws://127.0.0.1:${mediaPort}');
|
|
126
|
+
ws.binaryType = 'arraybuffer';
|
|
127
|
+
|
|
128
|
+
let canvas, ctx, vstream;
|
|
129
|
+
if (constraints.video) {
|
|
130
|
+
const w = constraints.video.width?.ideal || constraints.video.width?.max || 640;
|
|
131
|
+
const h = constraints.video.height?.ideal || constraints.video.height?.max || 480;
|
|
132
|
+
canvas = document.createElement('canvas');
|
|
133
|
+
canvas.width = w; canvas.height = h;
|
|
134
|
+
ctx = canvas.getContext('2d');
|
|
135
|
+
vstream = canvas.captureStream(15);
|
|
136
|
+
tracks.push(...vstream.getVideoTracks());
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let pcmQueue;
|
|
140
|
+
if (constraints.audio) {
|
|
141
|
+
const actx = new AudioContext({ sampleRate: 48000 });
|
|
142
|
+
if (actx.state === 'suspended') actx.resume();
|
|
143
|
+
pcmQueue = [];
|
|
144
|
+
// Use 1 input channel to ensure onaudioprocess fires reliably
|
|
145
|
+
const proc = actx.createScriptProcessor(4096, 1, 1);
|
|
146
|
+
// Feed silence into input to keep processor running
|
|
147
|
+
const silence = actx.createBufferSource();
|
|
148
|
+
const silenceBuf = actx.createBuffer(1, 4096, 48000);
|
|
149
|
+
silence.buffer = silenceBuf;
|
|
150
|
+
silence.loop = true;
|
|
151
|
+
silence.connect(proc);
|
|
152
|
+
silence.start();
|
|
153
|
+
proc.onaudioprocess = (e) => {
|
|
154
|
+
const out = e.outputBuffer.getChannelData(0);
|
|
155
|
+
const chunk = pcmQueue.shift();
|
|
156
|
+
if (chunk) {
|
|
157
|
+
const s16 = new Int16Array(chunk);
|
|
158
|
+
for (let i = 0; i < out.length && i < s16.length; i++) out[i] = s16[i] / 32768;
|
|
159
|
+
if (s16.length < out.length) out.fill(0, s16.length);
|
|
160
|
+
} else {
|
|
161
|
+
out.fill(0);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const dest = actx.createMediaStreamDestination();
|
|
165
|
+
proc.connect(dest);
|
|
166
|
+
tracks.push(...dest.stream.getAudioTracks());
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
ws.onmessage = (e) => {
|
|
170
|
+
const buf = new Uint8Array(e.data);
|
|
171
|
+
const type = buf[0];
|
|
172
|
+
const payload = buf.slice(1);
|
|
173
|
+
if (type === 1 && canvas) {
|
|
174
|
+
createImageBitmap(new Blob([payload], { type: 'image/jpeg' }))
|
|
175
|
+
.then(bmp => { ctx.drawImage(bmp, 0, 0, canvas.width, canvas.height); });
|
|
176
|
+
} else if (type === 2 && pcmQueue) {
|
|
177
|
+
pcmQueue.push(payload.buffer);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const stream = new MediaStream(tracks);
|
|
182
|
+
// Close WebSocket when all tracks are stopped (camera/mic off)
|
|
183
|
+
for (const track of tracks) {
|
|
184
|
+
track.addEventListener('ended', () => {
|
|
185
|
+
if (stream.getTracks().every(t => t.readyState === 'ended')) {
|
|
186
|
+
ws.close();
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
resolve(stream);
|
|
191
|
+
});
|
|
145
192
|
};
|
|
146
|
-
navigator.mediaDevices.getUserMedia = fakeStream;
|
|
147
193
|
navigator.mediaDevices.enumerateDevices = () => Promise.resolve([
|
|
148
194
|
{ deviceId: 'default', kind: 'audioinput', label: 'Default Microphone', groupId: 'default' },
|
|
149
195
|
{ deviceId: 'default', kind: 'videoinput', label: 'Default Camera', groupId: 'default' },
|
|
@@ -178,7 +224,7 @@ export async function startBrowser() {
|
|
|
178
224
|
|
|
179
225
|
// Phase 2: CDP connection + page setup (navigation is done by the caller)
|
|
180
226
|
// Tries /json/new first, then /json/list, then Target.createTarget via browser CDP
|
|
181
|
-
export async function setupPage({ port, wsUrl }, { width, height, zoom = 1 } = {}) {
|
|
227
|
+
export async function setupPage({ port, wsUrl }, { width, height, zoom = 1, mediaPort = 0 } = {}) {
|
|
182
228
|
const config = loadConfig();
|
|
183
229
|
const lang = config.language;
|
|
184
230
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
@@ -240,7 +286,7 @@ export async function setupPage({ port, wsUrl }, { width, height, zoom = 1 } = {
|
|
|
240
286
|
wow64: false,
|
|
241
287
|
},
|
|
242
288
|
}),
|
|
243
|
-
client.send('Page.addScriptToEvaluateOnNewDocument', { source: buildStealthScript(lang, {
|
|
289
|
+
client.send('Page.addScriptToEvaluateOnNewDocument', { source: buildStealthScript(lang, { media: config.media, mediaPort }) }),
|
|
244
290
|
client.send('Browser.setDownloadBehavior', {
|
|
245
291
|
behavior: 'allowAndName', downloadPath: join(homedir(), 'Downloads'),
|
|
246
292
|
eventsEnabled: true,
|
package/lib/config.js
CHANGED
|
@@ -32,7 +32,7 @@ const DEFAULTS = {
|
|
|
32
32
|
searchUrl: 'https://www.google.com/search?q=',
|
|
33
33
|
transport: 'auto', // 'auto' | 'file' | 'inline'
|
|
34
34
|
format: 'auto', // 'auto' | 'png' | 'jpeg'
|
|
35
|
-
|
|
35
|
+
media: false, // Enable camera/mic via ffmpeg for WebRTC (requires ffmpeg)
|
|
36
36
|
language: detectLocale(), // System locale (override: "en-US", "ja", etc.)
|
|
37
37
|
};
|
|
38
38
|
|
package/lib/input.js
CHANGED
|
@@ -302,6 +302,7 @@ export function startInputHandling(client, cellWidth, cellHeight, bindings, paus
|
|
|
302
302
|
|
|
303
303
|
// Throttled mouse motion (mode 1003 generates events for every pixel)
|
|
304
304
|
let pendingMotion = null;
|
|
305
|
+
let lastClickTime = 0, lastClickX = 0, lastClickY = 0, lastClickCount = 0;
|
|
305
306
|
let _motionTimer = null;
|
|
306
307
|
const MOTION_INTERVAL = 16; // ms (~60fps)
|
|
307
308
|
function flushMotion() {
|
|
@@ -511,7 +512,21 @@ export function startInputHandling(client, cellWidth, cellHeight, bindings, paus
|
|
|
511
512
|
if (release) {
|
|
512
513
|
await dispatchMouse(client, 'mouseReleased', x, y, 'left');
|
|
513
514
|
} else {
|
|
514
|
-
|
|
515
|
+
// Detect double/triple click by timing and position
|
|
516
|
+
const now = Date.now();
|
|
517
|
+
const MULTI_CLICK_MS = 400;
|
|
518
|
+
const MULTI_CLICK_PX = 5;
|
|
519
|
+
if (now - lastClickTime < MULTI_CLICK_MS
|
|
520
|
+
&& Math.abs(x - lastClickX) < MULTI_CLICK_PX
|
|
521
|
+
&& Math.abs(y - lastClickY) < MULTI_CLICK_PX) {
|
|
522
|
+
lastClickCount = Math.min(lastClickCount + 1, 3);
|
|
523
|
+
} else {
|
|
524
|
+
lastClickCount = 1;
|
|
525
|
+
}
|
|
526
|
+
lastClickTime = now;
|
|
527
|
+
lastClickX = x;
|
|
528
|
+
lastClickY = y;
|
|
529
|
+
await dispatchMouse(client, 'mousePressed', x, y, 'left', lastClickCount);
|
|
515
530
|
}
|
|
516
531
|
} else if (cb === MOUSE_BTN_DRAG) {
|
|
517
532
|
await dispatchMouse(client, 'mouseMoved', x, y, 'left');
|
package/lib/media.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Real camera/mic capture via ffmpeg → WebSocket relay
|
|
2
|
+
// Two ffmpeg processes (video MJPEG, audio PCM) piped to a local WS server
|
|
3
|
+
|
|
4
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { WebSocketServer } from 'ws';
|
|
7
|
+
|
|
8
|
+
const IS_MAC = process.platform === 'darwin';
|
|
9
|
+
|
|
10
|
+
// Detect available devices via ffmpeg
|
|
11
|
+
function listDevices() {
|
|
12
|
+
const video = detectVideoDevice();
|
|
13
|
+
const audio = detectAudioDevice();
|
|
14
|
+
return { video, audio };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function detectVideoDevice() {
|
|
18
|
+
if (IS_MAC) {
|
|
19
|
+
const out = ffmpegProbe(['-f', 'avfoundation', '-list_devices', 'true', '-i', '']);
|
|
20
|
+
// Parse first video device from "AVFoundation video devices:" section (language-independent)
|
|
21
|
+
const videoSection = out.split(/audio devices/i)[0];
|
|
22
|
+
const m = videoSection.match(/\[(\d+)\]/);
|
|
23
|
+
if (!m) return null;
|
|
24
|
+
const idx = m[1];
|
|
25
|
+
// Probe supported framerate by attempting to open with invalid fps
|
|
26
|
+
const probe = ffmpegProbe(['-f', 'avfoundation', '-framerate', '1', '-video_size', '640x480', '-i', idx]);
|
|
27
|
+
const fpsMatch = probe.match(/640x480@\[(\d+)/);
|
|
28
|
+
const fps = fpsMatch ? parseInt(fpsMatch[1]) : 30;
|
|
29
|
+
return { idx, fps };
|
|
30
|
+
}
|
|
31
|
+
return existsSync('/dev/video0') ? '/dev/video0' : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function detectAudioDevice() {
|
|
35
|
+
if (IS_MAC) {
|
|
36
|
+
const out = ffmpegProbe(['-f', 'avfoundation', '-list_devices', 'true', '-i', '']);
|
|
37
|
+
// Parse first audio device from "AVFoundation audio devices:" section (language-independent)
|
|
38
|
+
const audioSection = out.split(/audio devices/i)[1];
|
|
39
|
+
const m = audioSection && audioSection.match(/\[(\d+)\]/);
|
|
40
|
+
return m ? ':' + m[1] : null;
|
|
41
|
+
}
|
|
42
|
+
// Linux: find first PulseAudio input source (not monitor)
|
|
43
|
+
try {
|
|
44
|
+
const out = execFileSync('pactl', ['list', 'sources', 'short'], { encoding: 'utf8' });
|
|
45
|
+
for (const line of out.trim().split('\n')) {
|
|
46
|
+
const parts = line.split('\t');
|
|
47
|
+
if (parts[1] && !parts[1].includes('.monitor')) return parts[1];
|
|
48
|
+
}
|
|
49
|
+
} catch {}
|
|
50
|
+
return 'default';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ffmpegProbe(args) {
|
|
54
|
+
try {
|
|
55
|
+
execFileSync('ffmpeg', args, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return (e.stderr || '') + (e.stdout || '');
|
|
58
|
+
}
|
|
59
|
+
return '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Parse MJPEG stream: extract individual JPEG frames from continuous byte stream
|
|
63
|
+
function createMjpegParser(onFrame) {
|
|
64
|
+
let buf = Buffer.alloc(0);
|
|
65
|
+
return (chunk) => {
|
|
66
|
+
buf = Buffer.concat([buf, chunk]);
|
|
67
|
+
for (;;) {
|
|
68
|
+
// Find SOI marker (0xFF 0xD8)
|
|
69
|
+
const soi = buf.indexOf(Buffer.from([0xff, 0xd8]));
|
|
70
|
+
if (soi < 0) { buf = Buffer.alloc(0); return; }
|
|
71
|
+
if (soi > 0) buf = buf.subarray(soi);
|
|
72
|
+
// Find EOI marker (0xFF 0xD9) after SOI
|
|
73
|
+
const eoi = buf.indexOf(Buffer.from([0xff, 0xd9]), 2);
|
|
74
|
+
if (eoi < 0) return; // Incomplete frame
|
|
75
|
+
const frame = buf.subarray(0, eoi + 2);
|
|
76
|
+
onFrame(frame);
|
|
77
|
+
buf = buf.subarray(eoi + 2);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Start media capture and WebSocket relay
|
|
83
|
+
export async function startMedia(config = {}) {
|
|
84
|
+
// Check ffmpeg availability
|
|
85
|
+
try {
|
|
86
|
+
execFileSync('ffmpeg', ['-version'], { stdio: 'pipe' });
|
|
87
|
+
} catch {
|
|
88
|
+
console.error('casty: ffmpeg is required for media capture.');
|
|
89
|
+
if (IS_MAC) console.error('casty: Install with: brew install ffmpeg');
|
|
90
|
+
else console.error('casty: Install with: sudo apt install ffmpeg');
|
|
91
|
+
return { port: 0, cleanup: () => {} };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const devices = listDevices();
|
|
95
|
+
const videoRaw = config.videoDevice || devices.video;
|
|
96
|
+
const audioDevice = config.audioDevice || devices.audio;
|
|
97
|
+
|
|
98
|
+
// Normalize video device: { idx, fps } on macOS, string on Linux
|
|
99
|
+
const videoDevice = videoRaw && typeof videoRaw === 'object' ? videoRaw.idx : videoRaw;
|
|
100
|
+
const videoFps = videoRaw && typeof videoRaw === 'object' ? videoRaw.fps : 15;
|
|
101
|
+
|
|
102
|
+
if (!videoDevice && !audioDevice) {
|
|
103
|
+
console.error('casty: no media devices found');
|
|
104
|
+
return { port: 0, cleanup: () => {} };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Start WebSocket server on random port
|
|
108
|
+
const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 });
|
|
109
|
+
const port = await new Promise(resolve => {
|
|
110
|
+
wss.on('listening', () => resolve(wss.address().port));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const clients = new Set();
|
|
114
|
+
const procs = [];
|
|
115
|
+
let capturing = false;
|
|
116
|
+
|
|
117
|
+
function broadcast(prefix, data) {
|
|
118
|
+
const msg = Buffer.concat([Buffer.from([prefix]), data]);
|
|
119
|
+
for (const ws of clients) {
|
|
120
|
+
if (ws.readyState === 1) ws.send(msg);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Start ffmpeg capture on demand (first WebSocket connection = getUserMedia called)
|
|
125
|
+
function startCapture() {
|
|
126
|
+
if (capturing) return;
|
|
127
|
+
capturing = true;
|
|
128
|
+
console.error('casty: media requested, starting capture');
|
|
129
|
+
|
|
130
|
+
// Video capture
|
|
131
|
+
if (videoDevice) {
|
|
132
|
+
const vargs = IS_MAC
|
|
133
|
+
? ['-f', 'avfoundation', '-framerate', String(videoFps), '-video_size', '640x480', '-i', videoDevice]
|
|
134
|
+
: ['-f', 'v4l2', '-framerate', '15', '-video_size', '640x480', '-i', videoDevice];
|
|
135
|
+
vargs.push('-f', 'mjpeg', '-q:v', '5', '-r', '15', 'pipe:1');
|
|
136
|
+
|
|
137
|
+
const vproc = spawn('ffmpeg', vargs, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
138
|
+
const parse = createMjpegParser((frame) => broadcast(0x01, frame));
|
|
139
|
+
vproc.stdout.on('data', parse);
|
|
140
|
+
vproc.stderr.on('data', () => {}); // Suppress
|
|
141
|
+
vproc.on('error', (e) => console.error('casty: video capture error:', e.message));
|
|
142
|
+
procs.push(vproc);
|
|
143
|
+
console.error(`casty: video capture started (${videoDevice})`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Audio capture
|
|
147
|
+
if (audioDevice) {
|
|
148
|
+
const aargs = IS_MAC
|
|
149
|
+
? ['-f', 'avfoundation', '-i', audioDevice]
|
|
150
|
+
: ['-f', 'pulse', '-i', audioDevice];
|
|
151
|
+
aargs.push('-f', 's16le', '-ar', '48000', '-ac', '1', 'pipe:1');
|
|
152
|
+
|
|
153
|
+
const env = { ...process.env };
|
|
154
|
+
if (!env.XDG_RUNTIME_DIR) env.XDG_RUNTIME_DIR = `/run/user/${process.getuid()}`;
|
|
155
|
+
const aproc = spawn('ffmpeg', aargs, { stdio: ['pipe', 'pipe', 'pipe'], env });
|
|
156
|
+
let pcmBuf = Buffer.alloc(0);
|
|
157
|
+
const PCM_CHUNK = 9600;
|
|
158
|
+
aproc.stdout.on('data', (chunk) => {
|
|
159
|
+
pcmBuf = Buffer.concat([pcmBuf, chunk]);
|
|
160
|
+
while (pcmBuf.length >= PCM_CHUNK) {
|
|
161
|
+
broadcast(0x02, pcmBuf.subarray(0, PCM_CHUNK));
|
|
162
|
+
pcmBuf = pcmBuf.subarray(PCM_CHUNK);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
aproc.stderr.on('data', () => {}); // Suppress
|
|
166
|
+
aproc.on('error', (e) => console.error('casty: audio capture error:', e.message));
|
|
167
|
+
procs.push(aproc);
|
|
168
|
+
console.error(`casty: audio capture started (${audioDevice})`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function stopCapture() {
|
|
173
|
+
if (!capturing) return;
|
|
174
|
+
for (const p of procs) {
|
|
175
|
+
try { p.kill(); } catch {}
|
|
176
|
+
}
|
|
177
|
+
procs.length = 0;
|
|
178
|
+
capturing = false;
|
|
179
|
+
console.error('casty: media stopped (no active sessions)');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Start capture when first client connects, stop when all disconnect
|
|
183
|
+
wss.on('connection', (ws) => {
|
|
184
|
+
clients.add(ws);
|
|
185
|
+
startCapture();
|
|
186
|
+
ws.on('close', () => {
|
|
187
|
+
clients.delete(ws);
|
|
188
|
+
if (clients.size === 0) stopCapture();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
console.error('casty: media ready (capture starts on demand)');
|
|
193
|
+
|
|
194
|
+
function cleanup() {
|
|
195
|
+
for (const p of procs) {
|
|
196
|
+
try { p.kill(); } catch {}
|
|
197
|
+
}
|
|
198
|
+
wss.close();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { port, cleanup };
|
|
202
|
+
}
|