@zeke/obsx 0.1.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/README.md +66 -0
- package/bin/obsx.js +2 -0
- package/dist/cli.js +47 -0
- package/dist/commands/add-images.js +205 -0
- package/dist/commands/add-webcam.js +341 -0
- package/dist/lib/obs.js +26 -0
- package/package.json +34 -0
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# obsx
|
|
2
|
+
|
|
3
|
+
A CLI for [OBS](https://obsproject.com/).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Run without installing:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npx zeke/obsx <command>
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Install globally:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm install -g zeke/obsx
|
|
17
|
+
obsx <command>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
This CLI connects to OBS via the built-in obs-websocket server (protocol v5).
|
|
23
|
+
|
|
24
|
+
- OBS: OBS 28+ (or obs-websocket 5.x installed)
|
|
25
|
+
- WebSocket server: enabled in OBS
|
|
26
|
+
- Default URL: `ws://localhost:4455`
|
|
27
|
+
- Authentication: none by default
|
|
28
|
+
|
|
29
|
+
In OBS, look for `Tools -> WebSocket Server Settings` (or similar) and set the port to `4455`.
|
|
30
|
+
|
|
31
|
+
Connection config is optional; by default it uses `ws://localhost:4455` with no password.
|
|
32
|
+
|
|
33
|
+
To override, set environment variables:
|
|
34
|
+
|
|
35
|
+
- `OBSX_URL` (default: `ws://localhost:4455`)
|
|
36
|
+
- `OBSX_PASSWORD` (optional)
|
|
37
|
+
|
|
38
|
+
Add a webcam source to the current scene:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
obsx add-webcam
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Or without installing:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
npx zeke/obsx add-webcam
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Interactive mode (hit enter to accept defaults):
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
obsx add-webcam --interactive
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Add image sources for all images in the current directory (skips ones already in the scene):
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
obsx add-images
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Use a specific directory:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
obsx add-images --dir "$PWD"
|
|
66
|
+
```
|
package/bin/obsx.js
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { addImages } from "./commands/add-images.js";
|
|
4
|
+
import { addWebcam } from "./commands/add-webcam.js";
|
|
5
|
+
function printHelp() {
|
|
6
|
+
console.log(`obsx - A CLI for OBS
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
obsx <command> [options]
|
|
10
|
+
|
|
11
|
+
Environment:
|
|
12
|
+
OBSX_URL OBS websocket URL (default: ws://localhost:4455)
|
|
13
|
+
OBSX_PASSWORD OBS websocket password (optional)
|
|
14
|
+
|
|
15
|
+
Commands:
|
|
16
|
+
add-images Add image sources for images in a directory (default: cwd)
|
|
17
|
+
add-webcam Add a webcam input to the current scene
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
npx zeke/obsx add-images
|
|
21
|
+
npx zeke/obsx add-images --dir /path/to/images
|
|
22
|
+
npx zeke/obsx add-webcam --interactive
|
|
23
|
+
`);
|
|
24
|
+
}
|
|
25
|
+
async function run(argv) {
|
|
26
|
+
const [maybeCommand, ...rest] = argv;
|
|
27
|
+
if (!maybeCommand || maybeCommand === "-h" || maybeCommand === "--help") {
|
|
28
|
+
printHelp();
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const command = maybeCommand;
|
|
32
|
+
if (command === "add-images") {
|
|
33
|
+
await addImages(rest);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (command === "add-webcam") {
|
|
37
|
+
await addWebcam(rest);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
console.error(`Unknown command: ${maybeCommand}`);
|
|
41
|
+
printHelp();
|
|
42
|
+
process.exitCode = 1;
|
|
43
|
+
}
|
|
44
|
+
run(process.argv.slice(2)).catch((err) => {
|
|
45
|
+
console.error(err);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { getObsConnectionOptionsFromEnv, withOBS } from "../lib/obs.js";
|
|
6
|
+
const IMAGE_EXTS = new Set([
|
|
7
|
+
".png",
|
|
8
|
+
".jpg",
|
|
9
|
+
".jpeg",
|
|
10
|
+
".gif",
|
|
11
|
+
".bmp",
|
|
12
|
+
".tif",
|
|
13
|
+
".tiff",
|
|
14
|
+
".webp",
|
|
15
|
+
]);
|
|
16
|
+
function parseArgs(argv) {
|
|
17
|
+
const out = {};
|
|
18
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
19
|
+
const arg = argv[i] ?? "";
|
|
20
|
+
const next = argv[i + 1];
|
|
21
|
+
if (arg === "--scene" && typeof next === "string") {
|
|
22
|
+
out.scene = next;
|
|
23
|
+
i += 1;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (arg === "--dir" && typeof next === "string") {
|
|
27
|
+
out.dir = next;
|
|
28
|
+
i += 1;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
function mergeOptions(overrides, cwd) {
|
|
35
|
+
return {
|
|
36
|
+
scene: overrides.scene,
|
|
37
|
+
dir: overrides.dir ?? cwd,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function expandHome(p) {
|
|
41
|
+
if (!p.startsWith("~/"))
|
|
42
|
+
return p;
|
|
43
|
+
return path.join(os.homedir(), p.slice(2));
|
|
44
|
+
}
|
|
45
|
+
function normalizeFilePath(p) {
|
|
46
|
+
const expanded = expandHome(p);
|
|
47
|
+
const resolved = path.resolve(expanded);
|
|
48
|
+
try {
|
|
49
|
+
return fs.realpathSync.native(resolved);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return resolved;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function listImagesInDir(dir) {
|
|
56
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
57
|
+
return entries
|
|
58
|
+
.filter((e) => e.isFile())
|
|
59
|
+
.map((e) => e.name)
|
|
60
|
+
.filter((name) => IMAGE_EXTS.has(path.extname(name).toLowerCase()))
|
|
61
|
+
.sort((a, b) => a.localeCompare(b))
|
|
62
|
+
.map((fileName) => ({
|
|
63
|
+
fileName,
|
|
64
|
+
filePath: normalizeFilePath(path.join(dir, fileName)),
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
async function pickImageInputKind(obs) {
|
|
68
|
+
const list = await obs.call("GetInputKindList");
|
|
69
|
+
const kinds = list.inputKinds ?? [];
|
|
70
|
+
if (kinds.includes("image_source"))
|
|
71
|
+
return "image_source";
|
|
72
|
+
const imageKind = kinds.find((k) => k.toLowerCase().includes("image"));
|
|
73
|
+
if (imageKind)
|
|
74
|
+
return imageKind;
|
|
75
|
+
throw new Error(`No image input kind found. Available: ${kinds.join(", ")}`);
|
|
76
|
+
}
|
|
77
|
+
async function getSceneItemSourceNames(obs, sceneName) {
|
|
78
|
+
const res = await obs.call("GetSceneItemList", { sceneName });
|
|
79
|
+
const items = (res.sceneItems ?? []);
|
|
80
|
+
const names = items
|
|
81
|
+
.map((item) => {
|
|
82
|
+
const anyItem = item;
|
|
83
|
+
return String(anyItem.sourceName ?? "");
|
|
84
|
+
})
|
|
85
|
+
.filter((n) => n.length);
|
|
86
|
+
return [...new Set(names)];
|
|
87
|
+
}
|
|
88
|
+
async function getExistingImageFilesBySourceName(obs, sourceNames) {
|
|
89
|
+
const out = new Map();
|
|
90
|
+
for (const sourceName of sourceNames) {
|
|
91
|
+
try {
|
|
92
|
+
const settings = await obs.call("GetInputSettings", { inputName: sourceName });
|
|
93
|
+
const file = settings.inputSettings?.file;
|
|
94
|
+
if (typeof file === "string" && file.trim().length) {
|
|
95
|
+
out.set(sourceName, normalizeFilePath(file));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Not an input with settings (e.g. a nested scene). Ignore.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
function computeFitTransform(canvasW, canvasH) {
|
|
105
|
+
return {
|
|
106
|
+
positionX: canvasW / 2,
|
|
107
|
+
positionY: canvasH / 2,
|
|
108
|
+
alignment: 0,
|
|
109
|
+
boundsType: "OBS_BOUNDS_SCALE_INNER",
|
|
110
|
+
boundsAlignment: 0,
|
|
111
|
+
boundsWidth: canvasW,
|
|
112
|
+
boundsHeight: canvasH,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
export async function addImages(argv, cwd = process.cwd()) {
|
|
116
|
+
const options = mergeOptions(parseArgs(argv), cwd);
|
|
117
|
+
const dir = normalizeFilePath(options.dir);
|
|
118
|
+
const images = listImagesInDir(dir);
|
|
119
|
+
if (!images.length) {
|
|
120
|
+
console.log(`No images found in: ${dir}`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
await withOBS(getObsConnectionOptionsFromEnv(), async (obs) => {
|
|
124
|
+
const currentScene = await obs.call("GetCurrentProgramScene");
|
|
125
|
+
const sceneName = options.scene ?? currentScene.currentProgramSceneName;
|
|
126
|
+
const [video, inputKind, sourceNames, inputList] = await Promise.all([
|
|
127
|
+
obs.call("GetVideoSettings"),
|
|
128
|
+
pickImageInputKind(obs),
|
|
129
|
+
getSceneItemSourceNames(obs, sceneName),
|
|
130
|
+
obs.call("GetInputList"),
|
|
131
|
+
]);
|
|
132
|
+
const canvasW = video.baseWidth;
|
|
133
|
+
const canvasH = video.baseHeight;
|
|
134
|
+
const existingNames = new Set(sourceNames);
|
|
135
|
+
const allInputNames = new Set((inputList.inputs ?? []).map((i) => i.inputName));
|
|
136
|
+
const existingFilesBySourceName = await getExistingImageFilesBySourceName(obs, sourceNames);
|
|
137
|
+
const existingFiles = new Set(existingFilesBySourceName.values());
|
|
138
|
+
let created = 0;
|
|
139
|
+
let skipped = 0;
|
|
140
|
+
for (const img of images) {
|
|
141
|
+
const inputName = img.fileName;
|
|
142
|
+
if (existingNames.has(inputName)) {
|
|
143
|
+
skipped += 1;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (existingFiles.has(img.filePath)) {
|
|
147
|
+
skipped += 1;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (allInputNames.has(inputName)) {
|
|
151
|
+
let existingInputFile;
|
|
152
|
+
try {
|
|
153
|
+
const settings = await obs.call("GetInputSettings", { inputName });
|
|
154
|
+
const file = settings.inputSettings?.file;
|
|
155
|
+
if (typeof file === "string" && file.trim().length) {
|
|
156
|
+
existingInputFile = normalizeFilePath(file);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
existingInputFile = undefined;
|
|
161
|
+
}
|
|
162
|
+
if (!existingInputFile || existingInputFile !== img.filePath) {
|
|
163
|
+
console.log(`Skipping ${img.fileName}: OBS input name already exists with a different file (${inputName})`);
|
|
164
|
+
skipped += 1;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const sceneItem = await obs.call("CreateSceneItem", {
|
|
168
|
+
sceneName,
|
|
169
|
+
sourceName: inputName,
|
|
170
|
+
sceneItemEnabled: true,
|
|
171
|
+
});
|
|
172
|
+
await obs.call("SetSceneItemTransform", {
|
|
173
|
+
sceneName,
|
|
174
|
+
sceneItemId: sceneItem.sceneItemId,
|
|
175
|
+
sceneItemTransform: computeFitTransform(canvasW, canvasH),
|
|
176
|
+
});
|
|
177
|
+
existingNames.add(inputName);
|
|
178
|
+
existingFiles.add(img.filePath);
|
|
179
|
+
created += 1;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const createdInput = await obs.call("CreateInput", {
|
|
183
|
+
sceneName,
|
|
184
|
+
inputName,
|
|
185
|
+
inputKind,
|
|
186
|
+
inputSettings: {
|
|
187
|
+
file: img.filePath,
|
|
188
|
+
},
|
|
189
|
+
sceneItemEnabled: true,
|
|
190
|
+
});
|
|
191
|
+
await obs.call("SetSceneItemTransform", {
|
|
192
|
+
sceneName,
|
|
193
|
+
sceneItemId: createdInput.sceneItemId,
|
|
194
|
+
sceneItemTransform: computeFitTransform(canvasW, canvasH),
|
|
195
|
+
});
|
|
196
|
+
existingNames.add(inputName);
|
|
197
|
+
existingFiles.add(img.filePath);
|
|
198
|
+
created += 1;
|
|
199
|
+
}
|
|
200
|
+
console.log(`Scene: ${sceneName}`);
|
|
201
|
+
console.log(`Dir: ${dir}`);
|
|
202
|
+
console.log(`Created: ${created}`);
|
|
203
|
+
console.log(`Skipped: ${skipped}`);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { getObsConnectionOptionsFromEnv, withOBS } from "../lib/obs.js";
|
|
4
|
+
const DEFAULTS = {
|
|
5
|
+
interactive: false,
|
|
6
|
+
baseName: "Video Capture Device",
|
|
7
|
+
inputKind: undefined,
|
|
8
|
+
deviceSelection: undefined,
|
|
9
|
+
addChromaKey: true,
|
|
10
|
+
addColorCorrection: true,
|
|
11
|
+
saturation: -1.0,
|
|
12
|
+
contrast: 0.7,
|
|
13
|
+
};
|
|
14
|
+
const DEVICE_PRIORITY = ["iphone camera", "studio display camera", "facetime hd camera"];
|
|
15
|
+
const INPUT_KIND_PREFERENCE = [
|
|
16
|
+
"av_capture_input",
|
|
17
|
+
"macos-avcapture",
|
|
18
|
+
"avf_capture_input",
|
|
19
|
+
"avfoundation_input",
|
|
20
|
+
"video_capture_device",
|
|
21
|
+
];
|
|
22
|
+
function parseArgs(argv) {
|
|
23
|
+
const out = {};
|
|
24
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
25
|
+
const arg = argv[i] ?? "";
|
|
26
|
+
const next = argv[i + 1];
|
|
27
|
+
if (arg === "-i" || arg === "--interactive") {
|
|
28
|
+
out.interactive = true;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (arg === "--base-name" && typeof next === "string") {
|
|
32
|
+
out.baseName = next;
|
|
33
|
+
i += 1;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (arg === "--input-kind" && typeof next === "string") {
|
|
37
|
+
out.inputKind = next;
|
|
38
|
+
i += 1;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (arg === "--device" && typeof next === "string") {
|
|
42
|
+
out.deviceSelection = next;
|
|
43
|
+
i += 1;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (arg === "--no-chroma-key") {
|
|
47
|
+
out.addChromaKey = false;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (arg === "--no-color-correction") {
|
|
51
|
+
out.addColorCorrection = false;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (arg === "--saturation" && typeof next === "string") {
|
|
55
|
+
out.saturation = Number(next);
|
|
56
|
+
i += 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (arg === "--contrast" && typeof next === "string") {
|
|
60
|
+
out.contrast = Number(next);
|
|
61
|
+
i += 1;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
function mergeOptions(overrides) {
|
|
68
|
+
return {
|
|
69
|
+
...DEFAULTS,
|
|
70
|
+
...overrides,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
async function ask(rl, question, defaultValue) {
|
|
74
|
+
const suffix = defaultValue.length ? ` [${defaultValue}]` : "";
|
|
75
|
+
const answer = (await rl.question(`${question}${suffix}: `)).trim();
|
|
76
|
+
return answer.length ? answer : defaultValue;
|
|
77
|
+
}
|
|
78
|
+
async function askYesNo(rl, question, defaultValue) {
|
|
79
|
+
const prompt = defaultValue ? "[Y/n]" : "[y/N]";
|
|
80
|
+
const answer = (await rl.question(`${question} ${prompt}: `)).trim().toLowerCase();
|
|
81
|
+
if (!answer)
|
|
82
|
+
return defaultValue;
|
|
83
|
+
if (["y", "yes"].includes(answer))
|
|
84
|
+
return true;
|
|
85
|
+
if (["n", "no"].includes(answer))
|
|
86
|
+
return false;
|
|
87
|
+
return defaultValue;
|
|
88
|
+
}
|
|
89
|
+
function pickDefaultIndexFromNeedles(labels, needles) {
|
|
90
|
+
for (const needle of needles) {
|
|
91
|
+
const idx = labels.findIndex((l) => l.toLowerCase().includes(needle));
|
|
92
|
+
if (idx !== -1)
|
|
93
|
+
return idx;
|
|
94
|
+
}
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
async function askChoice(rl, question, choices, defaultIndex) {
|
|
98
|
+
console.log("\n" + question);
|
|
99
|
+
for (let i = 0; i < choices.length; i += 1) {
|
|
100
|
+
const n = String(i + 1).padStart(2, " ");
|
|
101
|
+
console.log(` ${n}) ${choices[i].label}`);
|
|
102
|
+
}
|
|
103
|
+
const defaultHuman = String(defaultIndex + 1);
|
|
104
|
+
const answer = (await rl.question(`Select [${defaultHuman}]: `)).trim();
|
|
105
|
+
if (!answer)
|
|
106
|
+
return choices[defaultIndex].value;
|
|
107
|
+
const num = Number(answer);
|
|
108
|
+
if (Number.isInteger(num) && num >= 1 && num <= choices.length) {
|
|
109
|
+
return choices[num - 1].value;
|
|
110
|
+
}
|
|
111
|
+
const exact = choices.find((c) => c.value === answer) ?? choices.find((c) => c.label === answer);
|
|
112
|
+
if (exact)
|
|
113
|
+
return exact.value;
|
|
114
|
+
return choices[defaultIndex].value;
|
|
115
|
+
}
|
|
116
|
+
async function pickInputKind(obs) {
|
|
117
|
+
const list = await obs.call("GetInputKindList");
|
|
118
|
+
const kinds = list.inputKinds ?? [];
|
|
119
|
+
for (const preferred of INPUT_KIND_PREFERENCE) {
|
|
120
|
+
if (kinds.includes(preferred))
|
|
121
|
+
return preferred;
|
|
122
|
+
}
|
|
123
|
+
const captureKind = kinds.find((kind) => kind.toLowerCase().includes("capture"));
|
|
124
|
+
if (captureKind)
|
|
125
|
+
return captureKind;
|
|
126
|
+
throw new Error(`No supported capture input kinds found: ${kinds.join(", ")}`);
|
|
127
|
+
}
|
|
128
|
+
async function uniqueInputName(obs, baseName) {
|
|
129
|
+
const list = await obs.call("GetInputList");
|
|
130
|
+
const names = new Set(list.inputs.map((input) => input.inputName));
|
|
131
|
+
if (!names.has(baseName))
|
|
132
|
+
return baseName;
|
|
133
|
+
let suffix = 2;
|
|
134
|
+
while (names.has(`${baseName}-${suffix}`))
|
|
135
|
+
suffix += 1;
|
|
136
|
+
return `${baseName}-${suffix}`;
|
|
137
|
+
}
|
|
138
|
+
async function getDeviceChoices(obs, inputName) {
|
|
139
|
+
const propertyCandidates = [
|
|
140
|
+
"device_id",
|
|
141
|
+
"device",
|
|
142
|
+
"device_name",
|
|
143
|
+
"video_device",
|
|
144
|
+
"source",
|
|
145
|
+
"input",
|
|
146
|
+
];
|
|
147
|
+
for (const propertyName of propertyCandidates) {
|
|
148
|
+
try {
|
|
149
|
+
const props = await obs.call("GetInputPropertiesListPropertyItems", {
|
|
150
|
+
inputName,
|
|
151
|
+
propertyName,
|
|
152
|
+
});
|
|
153
|
+
const items = props.propertyItems ?? [];
|
|
154
|
+
const choices = items
|
|
155
|
+
.map((item) => ({
|
|
156
|
+
propertyName,
|
|
157
|
+
itemName: String(item.itemName ?? ""),
|
|
158
|
+
itemValue: String(item.itemValue ?? ""),
|
|
159
|
+
}))
|
|
160
|
+
.filter((c) => c.itemName.length || c.itemValue.length);
|
|
161
|
+
if (choices.length)
|
|
162
|
+
return choices;
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
166
|
+
if (!message.includes("Unable to find a property"))
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const settings = await obs.call("GetInputSettings", { inputName });
|
|
171
|
+
const settingKeys = Object.keys(settings.inputSettings ?? {});
|
|
172
|
+
console.log("Available input settings keys:", settingKeys);
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
function pickDeviceDefaultIndex(choices) {
|
|
176
|
+
const labels = choices.map((c) => c.itemName);
|
|
177
|
+
return pickDefaultIndexFromNeedles(labels, DEVICE_PRIORITY);
|
|
178
|
+
}
|
|
179
|
+
function findDeviceIndexBySelection(choices, selection) {
|
|
180
|
+
const needle = selection.trim().toLowerCase();
|
|
181
|
+
if (!needle)
|
|
182
|
+
return -1;
|
|
183
|
+
return choices.findIndex((c) => `${c.itemName} ${c.itemValue} ${c.propertyName}`.toLowerCase().includes(needle));
|
|
184
|
+
}
|
|
185
|
+
async function resolveOptionsInteractive(initial) {
|
|
186
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
187
|
+
throw new Error("Interactive mode requires a TTY.");
|
|
188
|
+
}
|
|
189
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
190
|
+
try {
|
|
191
|
+
const baseName = await ask(rl, "Base source name", initial.baseName);
|
|
192
|
+
const addChromaKey = await askYesNo(rl, "Add Chroma Key filter?", initial.addChromaKey);
|
|
193
|
+
const addColorCorrection = await askYesNo(rl, "Add Color Correction filter?", initial.addColorCorrection);
|
|
194
|
+
let saturation = initial.saturation;
|
|
195
|
+
let contrast = initial.contrast;
|
|
196
|
+
if (addColorCorrection) {
|
|
197
|
+
const satRaw = await ask(rl, "Color Correction: saturation", String(initial.saturation));
|
|
198
|
+
const conRaw = await ask(rl, "Color Correction: contrast", String(initial.contrast));
|
|
199
|
+
saturation = Number(satRaw);
|
|
200
|
+
contrast = Number(conRaw);
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
...initial,
|
|
204
|
+
baseName,
|
|
205
|
+
addChromaKey,
|
|
206
|
+
addColorCorrection,
|
|
207
|
+
saturation,
|
|
208
|
+
contrast,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
rl.close();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
export async function addWebcam(argv) {
|
|
216
|
+
const argOverrides = parseArgs(argv);
|
|
217
|
+
let options = mergeOptions(argOverrides);
|
|
218
|
+
if (options.interactive) {
|
|
219
|
+
options = await resolveOptionsInteractive(options);
|
|
220
|
+
}
|
|
221
|
+
if (!Number.isFinite(options.saturation)) {
|
|
222
|
+
throw new Error(`Invalid --saturation: ${options.saturation}`);
|
|
223
|
+
}
|
|
224
|
+
if (!Number.isFinite(options.contrast)) {
|
|
225
|
+
throw new Error(`Invalid --contrast: ${options.contrast}`);
|
|
226
|
+
}
|
|
227
|
+
await withOBS(getObsConnectionOptionsFromEnv(), async (obs) => {
|
|
228
|
+
const currentScene = await obs.call("GetCurrentProgramScene");
|
|
229
|
+
const sceneName = currentScene.currentProgramSceneName;
|
|
230
|
+
let inputKind = options.inputKind;
|
|
231
|
+
if (!inputKind)
|
|
232
|
+
inputKind = await pickInputKind(obs);
|
|
233
|
+
if (options.interactive) {
|
|
234
|
+
const list = await obs.call("GetInputKindList");
|
|
235
|
+
const kinds = list.inputKinds ?? [];
|
|
236
|
+
const preferredDefault = kinds.includes(inputKind) ? inputKind : await pickInputKind(obs);
|
|
237
|
+
const captureKinds = kinds
|
|
238
|
+
.filter((k) => k.toLowerCase().includes("capture") || INPUT_KIND_PREFERENCE.includes(k))
|
|
239
|
+
.sort((a, b) => {
|
|
240
|
+
const ai = INPUT_KIND_PREFERENCE.indexOf(a);
|
|
241
|
+
const bi = INPUT_KIND_PREFERENCE.indexOf(b);
|
|
242
|
+
if (ai === -1 && bi === -1)
|
|
243
|
+
return a.localeCompare(b);
|
|
244
|
+
if (ai === -1)
|
|
245
|
+
return 1;
|
|
246
|
+
if (bi === -1)
|
|
247
|
+
return -1;
|
|
248
|
+
return ai - bi;
|
|
249
|
+
});
|
|
250
|
+
const choices = (captureKinds.length ? captureKinds : kinds).map((k) => ({
|
|
251
|
+
label: k,
|
|
252
|
+
value: k,
|
|
253
|
+
}));
|
|
254
|
+
const defaultIndex = Math.max(0, choices.findIndex((c) => c.value === preferredDefault));
|
|
255
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
256
|
+
try {
|
|
257
|
+
inputKind = await askChoice(rl, "Input kind", choices, defaultIndex);
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
rl.close();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const inputName = await uniqueInputName(obs, options.baseName);
|
|
264
|
+
await obs.call("CreateInput", {
|
|
265
|
+
sceneName,
|
|
266
|
+
inputName,
|
|
267
|
+
inputKind,
|
|
268
|
+
inputSettings: {},
|
|
269
|
+
sceneItemEnabled: true,
|
|
270
|
+
});
|
|
271
|
+
const deviceChoices = await getDeviceChoices(obs, inputName);
|
|
272
|
+
if (!deviceChoices.length) {
|
|
273
|
+
throw new Error(`No capture devices found for ${inputKind}.`);
|
|
274
|
+
}
|
|
275
|
+
let deviceIndex = pickDeviceDefaultIndex(deviceChoices);
|
|
276
|
+
if (options.deviceSelection) {
|
|
277
|
+
const idx = findDeviceIndexBySelection(deviceChoices, options.deviceSelection);
|
|
278
|
+
if (idx !== -1)
|
|
279
|
+
deviceIndex = idx;
|
|
280
|
+
}
|
|
281
|
+
if (options.interactive) {
|
|
282
|
+
const labels = deviceChoices.map((c) => `${c.itemName} (${c.propertyName})`);
|
|
283
|
+
const defaultIndex = pickDefaultIndexFromNeedles(labels, DEVICE_PRIORITY);
|
|
284
|
+
const choices = deviceChoices.map((c) => ({
|
|
285
|
+
label: `${c.itemName} (${c.propertyName})`,
|
|
286
|
+
value: `${c.propertyName}::${c.itemValue}`,
|
|
287
|
+
}));
|
|
288
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
289
|
+
try {
|
|
290
|
+
const picked = await askChoice(rl, "Device", choices, defaultIndex);
|
|
291
|
+
const [propertyName, itemValue] = picked.split("::");
|
|
292
|
+
const idx = deviceChoices.findIndex((c) => c.propertyName === propertyName && c.itemValue === itemValue);
|
|
293
|
+
if (idx !== -1)
|
|
294
|
+
deviceIndex = idx;
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
rl.close();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const device = deviceChoices[deviceIndex];
|
|
301
|
+
await obs.call("SetInputSettings", {
|
|
302
|
+
inputName,
|
|
303
|
+
inputSettings: {
|
|
304
|
+
[device.propertyName]: device.itemValue,
|
|
305
|
+
},
|
|
306
|
+
overlay: true,
|
|
307
|
+
});
|
|
308
|
+
if (options.addChromaKey) {
|
|
309
|
+
await obs.call("CreateSourceFilter", {
|
|
310
|
+
sourceName: inputName,
|
|
311
|
+
filterName: "Chroma Key",
|
|
312
|
+
filterKind: "chroma_key_filter",
|
|
313
|
+
filterSettings: {},
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
if (options.addColorCorrection) {
|
|
317
|
+
await obs.call("CreateSourceFilter", {
|
|
318
|
+
sourceName: inputName,
|
|
319
|
+
filterName: "Color Correction",
|
|
320
|
+
filterKind: "color_filter",
|
|
321
|
+
filterSettings: {
|
|
322
|
+
saturation: options.saturation,
|
|
323
|
+
contrast: options.contrast,
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
const filters = await obs.call("GetSourceFilterList", {
|
|
328
|
+
sourceName: inputName,
|
|
329
|
+
});
|
|
330
|
+
const filterSummaries = filters.filters.map((filter) => ({
|
|
331
|
+
name: filter.filterName,
|
|
332
|
+
kind: filter.filterKind,
|
|
333
|
+
enabled: filter.filterEnabled,
|
|
334
|
+
}));
|
|
335
|
+
console.log("Filters:", filterSummaries);
|
|
336
|
+
console.log("Created input:", inputName);
|
|
337
|
+
console.log("Scene:", sceneName);
|
|
338
|
+
console.log("Input kind:", inputKind);
|
|
339
|
+
console.log("Device:", device.itemName);
|
|
340
|
+
});
|
|
341
|
+
}
|
package/dist/lib/obs.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import OBSWebSocket from "obs-websocket-js";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
export const DEFAULT_OBS_URL = "ws://localhost:4455";
|
|
4
|
+
export function getObsConnectionOptionsFromEnv() {
|
|
5
|
+
const url = (process.env.OBSX_URL ?? "").trim() || DEFAULT_OBS_URL;
|
|
6
|
+
const passwordRaw = (process.env.OBSX_PASSWORD ?? "").trim();
|
|
7
|
+
return {
|
|
8
|
+
url,
|
|
9
|
+
password: passwordRaw.length ? passwordRaw : undefined,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export async function withOBS(options, fn) {
|
|
13
|
+
const obs = new OBSWebSocket();
|
|
14
|
+
await obs.connect(options.url, options.password);
|
|
15
|
+
try {
|
|
16
|
+
return await fn(obs);
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
try {
|
|
20
|
+
await obs.disconnect();
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// ignore disconnect errors
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zeke/obsx",
|
|
3
|
+
"description": "A CLI for OBS",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"obsx": "./bin/obsx.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "node ./node_modules/typescript/bin/tsc -p tsconfig.json",
|
|
16
|
+
"prepack": "npm run build",
|
|
17
|
+
"dev": "tsx src/cli.ts",
|
|
18
|
+
"lint": "eslint ."
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"obs-websocket-js": "^5.0.4"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^20.11.30",
|
|
28
|
+
"@eslint/js": "^9.20.0",
|
|
29
|
+
"eslint": "^9.20.1",
|
|
30
|
+
"tsx": "^4.19.1",
|
|
31
|
+
"typescript": "^5.4.2",
|
|
32
|
+
"typescript-eslint": "^8.24.1"
|
|
33
|
+
}
|
|
34
|
+
}
|