Record your screen to video from Claude Code. Guided capture setup: pick a display, window, or screen region, then start/stop recording on demand. Uses ffmpeg — cross-platform (macOS, Linux, Windows). Produces MP4 with sensible defaults. Pairs with demo-narrate for voice-over. Triggers on: screencast, record screen, screen recording, capture screen, record window, record region, start recording, screen capture video.
100
100%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Passed
No known issues
#!/usr/bin/env node
'use strict';
const { execSync, spawn, spawnSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const STATE_FILE = path.join(os.homedir(), '.screencast.state');
function die(msg) {
process.stderr.write(JSON.stringify({ error: msg }) + '\n');
process.exit(1);
}
function json(obj) {
console.log(JSON.stringify(obj, null, 2));
}
function parseArgs(argv) {
const flags = { fps: 30, screen: 0, region: null, window: null, output: null };
const positional = [];
const raw = argv.slice(2);
for (let i = 0; i < raw.length; i++) {
switch (raw[i]) {
case '--fps':
if (i + 1 >= raw.length) die('--fps requires a value');
flags.fps = parseInt(raw[++i], 10);
if (Number.isNaN(flags.fps)) die('--fps requires a numeric value');
break;
case '--screen':
if (i + 1 >= raw.length) die('--screen requires a value');
flags.screen = parseInt(raw[++i], 10);
if (Number.isNaN(flags.screen)) die('--screen requires a numeric value');
break;
case '--region': flags.region = raw[++i]; break;
case '--window': flags.window = raw[++i]; break;
case '--output': flags.output = raw[++i]; break;
default:
if (raw[i].startsWith('--')) die(`Unknown flag: ${raw[i]}`);
positional.push(raw[i]);
}
}
return { command: positional[0], args: positional.slice(1), ...flags };
}
// ─── Platform detection ───────────────────────────────────────────────────────
const BACKEND_MAP = {
darwin: 'avfoundation',
linux: 'x11grab',
win32: 'gdigrab',
};
function detectPlatform() {
const platform = os.platform();
const backend = BACKEND_MAP[platform] ?? 'x11grab';
let ffmpeg = false;
let ffmpegVersion = null;
try {
const out = execSync('ffmpeg -version', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
ffmpeg = true;
const match = out.match(/ffmpeg version (\S+)/);
ffmpegVersion = match ? match[1] : 'unknown';
} catch {
// ffmpeg not found
}
return { platform, backend, ffmpeg, ffmpegVersion };
}
// ─── ffmpeg command builder ───────────────────────────────────────────────────
/**
* Parse a region string "x,y,w,h" into numeric parts.
* @param {string} region
* @returns {{ x: number, y: number, w: number, h: number }}
*/
function parseRegion(region) {
const parts = region.split(',').map(Number);
if (parts.length !== 4 || parts.some(isNaN)) {
throw new Error(`Invalid region "${region}": expected "x,y,width,height"`);
}
const [x, y, w, h] = parts;
return { x, y, w, h };
}
/**
* Build an array of ffmpeg CLI args for screen recording.
* @param {{
* backend: string,
* screenInput: string|null,
* fps: number,
* region: string|null,
* output: string,
* screenSize: string,
* display: string|null
* }} opts
* @returns {string[]}
*/
function buildFfmpegArgs({ backend, screenInput, fps, region, output, screenSize, display }) {
const args = ['-y'];
if (backend === 'avfoundation') {
args.push('-f', 'avfoundation');
args.push('-framerate', String(fps));
args.push('-capture_cursor', '1');
args.push('-i', `${screenInput}:none`);
if (region) {
const { x, y, w, h } = parseRegion(region);
args.push('-vf', `crop=${w}:${h}:${x}:${y}`);
}
} else if (backend === 'x11grab') {
args.push('-f', 'x11grab');
args.push('-framerate', String(fps));
if (region) {
const { x, y, w, h } = parseRegion(region);
args.push('-video_size', `${w}x${h}`);
args.push('-i', `${display}+${x},${y}`);
} else {
args.push('-video_size', screenSize);
args.push('-i', display);
}
} else if (backend === 'gdigrab') {
args.push('-f', 'gdigrab');
args.push('-framerate', String(fps));
if (region) {
const { x, y, w, h } = parseRegion(region);
args.push('-offset_x', String(x));
args.push('-offset_y', String(y));
args.push('-video_size', `${w}x${h}`);
}
args.push('-i', 'desktop');
}
// Common output codec settings
args.push('-c:v', 'libx264');
args.push('-pix_fmt', 'yuv420p');
args.push('-preset', 'ultrafast');
args.push('-crf', '23');
args.push(output);
return args;
}
// ─── Subcommands ──────────────────────────────────────────────────────────────
function cmdDeps() {
const info = detectPlatform();
if (!info.ffmpeg) {
json({ error: 'ffmpeg not found. Install via: brew install ffmpeg', ...info });
process.exit(1);
}
json(info);
}
function listWindowsDarwin() {
// JXA script — no single quotes; use castRefToObject to unwrap CFDictionary entries
const jxaScript = [
'ObjC.import("CoreGraphics");',
'ObjC.import("Foundation");',
'var raw = $.CGWindowListCopyWindowInfo(1, 0);',
'var count = $.CFArrayGetCount(raw);',
'var result = [];',
'for (var i = 0; i < count; i++) {',
' var info = ObjC.deepUnwrap(ObjC.castRefToObject($.CFArrayGetValueAtIndex(raw, i)));',
' if (!info || info.kCGWindowLayer !== 0) continue;',
' if (!info.kCGWindowOwnerName) continue;',
' var b = info.kCGWindowBounds || {};',
' result.push({',
' id: info.kCGWindowNumber,',
' app: info.kCGWindowOwnerName,',
' title: info.kCGWindowName || "",',
' x: b.X || 0,',
' y: b.Y || 0,',
' w: b.Width || 0,',
' h: b.Height || 0',
' });',
'}',
'JSON.stringify(result);',
].join('\n');
const result = spawnSync('osascript', ['-l', 'JavaScript', '-e', jxaScript], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
if (result.status !== 0) {
die(`osascript failed: ${result.stderr}`);
}
const windows = JSON.parse(result.stdout.trim());
json(windows);
}
function listWindowsLinux() {
const out = execSync('wmctrl -lG', { encoding: 'utf8' });
const windows = [];
for (const line of out.split('\n')) {
const m = line.match(/^(0x\w+)\s+\d+\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+\S+\s+(.*)/);
if (!m) continue;
windows.push({
id: m[1],
app: '',
title: m[6].trim(),
x: parseInt(m[2], 10),
y: parseInt(m[3], 10),
w: parseInt(m[4], 10),
h: parseInt(m[5], 10),
});
}
json(windows);
}
function listWindowsWindows() {
const ps = `
Add-Type @"
using System;
using System.Runtime.InteropServices;
using System.Text;
public class Win32 {
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll")] public static extern int GetWindowText(IntPtr hWnd, StringBuilder s, int n);
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT r);
public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
}
"@
$list = [System.Collections.Generic.List[object]]::new()
[Win32]::EnumWindows({
param($hWnd)
if ([Win32]::IsWindowVisible($hWnd)) {
$sb = [System.Text.StringBuilder]::new(256)
[Win32]::GetWindowText($hWnd, $sb, 256) | Out-Null
$t = $sb.ToString()
if ($t.Length -gt 0) {
$r = [Win32+RECT]::new()
[Win32]::GetWindowRect($hWnd, [ref]$r) | Out-Null
$list.Add([PSCustomObject]@{
id = $hWnd.ToInt64(); app = ""; title = $t;
x = $r.Left; y = $r.Top; w = $r.Right - $r.Left; h = $r.Bottom - $r.Top
})
}
}
return $true
}, [IntPtr]::Zero) | Out-Null
$list | ConvertTo-Json -Compress
`.trim();
const out = execSync(`powershell -Command "${ps}"`, { encoding: 'utf8' });
const windows = JSON.parse(out.trim());
json(Array.isArray(windows) ? windows : [windows]);
}
function cmdListWindows() {
const { platform } = detectPlatform();
if (platform === 'darwin') {
listWindowsDarwin();
} else if (platform === 'linux') {
listWindowsLinux();
} else {
listWindowsWindows();
}
}
function getDisplayInfo() {
try {
const out = execSync('system_profiler SPDisplaysDataType -json', {
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
});
const displays = JSON.parse(out).SPDisplaysDataType?.[0]?.spdisplays_ndrvs ?? [];
return displays.map((d) => {
const resStr = d['_spdisplays_resolution'] ?? d['spdisplays_resolution'] ?? '';
const rm = resStr.match(/(\d+)\s*[xX×]\s*(\d+)/);
const isRetina = (d['spdisplays_retina'] ?? '') === 'spdisplays_yes';
return {
width: rm ? parseInt(rm[1], 10) : null,
height: rm ? parseInt(rm[2], 10) : null,
scale: isRetina ? 2 : 1,
};
});
} catch {
return [];
}
}
function listScreensDarwin() {
// Get device indices from avfoundation
const devOut = spawnSync('ffmpeg', ['-f', 'avfoundation', '-list_devices', 'true', '-i', ''], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
const devText = (devOut.stdout || '') + (devOut.stderr || '');
const screens = [];
const re = /\[(\d+)\]\s+Capture screen (\d+)/g;
let m;
while ((m = re.exec(devText)) !== null) {
screens.push({ index: parseInt(m[1], 10), name: `Capture screen ${m[2]}` });
}
const displayData = getDisplayInfo();
const result = screens.map((s, i) => ({
index: s.index,
name: s.name,
width: displayData[i]?.width ?? null,
height: displayData[i]?.height ?? null,
scale: displayData[i]?.scale ?? 1,
}));
json(result);
}
function listScreensLinux() {
const out = execSync('xrandr --query', { encoding: 'utf8' });
const re = /^(\S+)\s+connected.*?(\d+)x(\d+)\+\d+\+\d+/gm;
const screens = [];
let m;
let index = 0;
while ((m = re.exec(out)) !== null) {
screens.push({
index,
name: m[1],
width: parseInt(m[2], 10),
height: parseInt(m[3], 10),
scale: 1,
});
index++;
}
json(screens);
}
function listScreensWindows() {
const ps = `
Add-Type -AssemblyName System.Windows.Forms;
$screens = [System.Windows.Forms.Screen]::AllScreens;
$arr = @();
for ($i = 0; $i -lt $screens.Length; $i++) {
$s = $screens[$i];
$arr += [PSCustomObject]@{
index = $i;
name = $s.DeviceName;
width = $s.Bounds.Width;
height = $s.Bounds.Height;
scale = 1;
};
}
$arr | ConvertTo-Json -Compress
`.trim();
const out = execSync(`powershell -Command "${ps}"`, { encoding: 'utf8' });
const screens = JSON.parse(out.trim());
json(Array.isArray(screens) ? screens : [screens]);
}
function cmdListScreens() {
const { platform } = detectPlatform();
if (platform === 'darwin') {
listScreensDarwin();
} else if (platform === 'linux') {
listScreensLinux();
} else {
listScreensWindows();
}
}
// ─── Window geometry resolution ──────────────────────────────────────────────
/**
* Get the screen-space geometry of a window by its CGWindowNumber.
* @param {number|string} windowId
* @returns {{ x: number, y: number, w: number, h: number }}
*/
function resolveWindowGeometryDarwin(windowId) {
const id = parseInt(String(windowId), 10);
const jxaScript = [
'ObjC.import("CoreGraphics");',
'ObjC.import("Foundation");',
`var targetId = ${id};`,
'var raw = $.CGWindowListCopyWindowInfo(1, 0);',
'var count = $.CFArrayGetCount(raw);',
'var found = null;',
'for (var i = 0; i < count; i++) {',
' var info = ObjC.deepUnwrap(ObjC.castRefToObject($.CFArrayGetValueAtIndex(raw, i)));',
' if (info && info.kCGWindowNumber === targetId) { found = info; break; }',
'}',
'if (!found) throw new Error("Window " + targetId + " not found");',
'var b = found.kCGWindowBounds;',
'JSON.stringify({ x: b.X, y: b.Y, w: b.Width, h: b.Height });',
].join('\n');
const result = spawnSync('osascript', ['-l', 'JavaScript', '-e', jxaScript], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
if (result.status !== 0) throw new Error(`osascript failed: ${result.stderr}`);
return JSON.parse(result.stdout.trim());
}
/**
* Get the geometry of a window by ID on Linux via xdotool.
* @param {string} windowId
* @returns {{ x: number, y: number, w: number, h: number }}
*/
function resolveWindowGeometryLinux(windowId) {
const out = execSync(`xdotool getwindowgeometry --shell ${windowId}`, { encoding: 'utf8' });
const getVal = (key) => {
const m = out.match(new RegExp(`${key}=(\\d+)`));
return m ? parseInt(m[1], 10) : 0;
};
return { x: getVal('X'), y: getVal('Y'), w: getVal('WIDTH'), h: getVal('HEIGHT') };
}
/**
* Get the geometry of a window by HWND on Windows via PowerShell.
* @param {string|number} windowId
* @returns {{ x: number, y: number, w: number, h: number }}
*/
function resolveWindowGeometryWindows(windowId) {
const ps = `
Add-Type @"
using System.Runtime.InteropServices;
public class Win32 {
[DllImport("user32.dll")] public static extern bool GetWindowRect(System.IntPtr h, out RECT r);
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
}
"@
$h = [System.IntPtr]${windowId}
$r = [Win32+RECT]::new()
[Win32]::GetWindowRect($h, [ref]$r) | Out-Null
[PSCustomObject]@{ x=$r.Left; y=$r.Top; w=$r.Right-$r.Left; h=$r.Bottom-$r.Top } | ConvertTo-Json -Compress
`.trim();
const out = execSync(`powershell -Command "${ps}"`, { encoding: 'utf8' });
return JSON.parse(out.trim());
}
/**
* Resolve the pixel geometry for a window ID on the current platform.
* On macOS, multiplies by the Retina scale factor.
* @param {number|string} windowId
* @returns {{ x: number, y: number, w: number, h: number }}
*/
function resolveWindowGeometry(windowId) {
const platform = os.platform();
if (platform === 'darwin') {
const geo = resolveWindowGeometryDarwin(windowId);
const displays = getDisplayInfo();
const scale = displays[0]?.scale ?? 1;
return {
x: geo.x * scale,
y: geo.y * scale,
w: geo.w * scale,
h: geo.h * scale,
};
} else if (platform === 'linux') {
return resolveWindowGeometryLinux(windowId);
} else {
return resolveWindowGeometryWindows(windowId);
}
}
// ─── State management ─────────────────────────────────────────────────────────
/**
* Read recording state from disk.
* @param {string} [stateFile]
* @returns {object|null}
*/
function readState(stateFile = STATE_FILE) {
try {
return JSON.parse(fs.readFileSync(stateFile, 'utf8'));
} catch {
return null;
}
}
/**
* Write recording state to disk.
* @param {object} state
* @param {string} [stateFile]
*/
function writeState(state, stateFile = STATE_FILE) {
fs.writeFileSync(stateFile, JSON.stringify(state), 'utf8');
}
/**
* Remove state file.
* @param {string} [stateFile]
*/
function clearState(stateFile = STATE_FILE) {
try {
fs.unlinkSync(stateFile);
} catch {
// file already gone — that's fine
}
}
/**
* Check whether a process is alive by sending signal 0.
* @param {number} pid
* @returns {boolean}
*/
function isAlive(pid) {
if (os.platform() === 'win32') {
try {
const out = execSync(`tasklist /FI "PID eq ${pid}" /NH`, {
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
});
return out.includes(String(pid));
} catch {
return false;
}
}
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
/**
* Resolve the avfoundation device index for a given screen number.
* Returns flags.screen as-is if parsing fails.
* @param {number} screenNum
* @returns {number}
*/
function resolveAvfoundationIndex(screenNum) {
try {
const devOut = spawnSync('ffmpeg', ['-f', 'avfoundation', '-list_devices', 'true', '-i', ''], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
const devText = (devOut.stdout || '') + (devOut.stderr || '');
const re = /\[(\d+)\]\s+Capture screen (\d+)/g;
let m;
const indexMap = {};
while ((m = re.exec(devText)) !== null) {
indexMap[parseInt(m[2], 10)] = parseInt(m[1], 10);
}
return indexMap[screenNum] ?? screenNum;
} catch {
return screenNum;
}
}
// ─── Interactive picker (macOS only) ─────────────────────────────────────────
/**
* Locate the Swift picker source file.
* @returns {string}
*/
function findPickerSource() {
const candidates = [
process.env.CLAUDE_SKILL_DIR
? path.join(process.env.CLAUDE_SKILL_DIR, 'scripts', 'screencast-picker.swift')
: null,
path.join(__dirname, 'screencast-picker.swift'),
].filter(Boolean);
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
throw new Error(
'screencast-picker.swift not found. Expected in same directory as screencast.js.'
);
}
/**
* Compile the Swift picker if needed. Returns path to binary.
* @param {string} [sourceFile] - Override source path (for testing)
* @param {string} [cacheDir] - Override cache directory (for testing)
* @returns {string}
*/
function compilePicker(sourceFile, cacheDir) {
if (os.platform() !== 'darwin') {
throw new Error('Interactive selection is only available on macOS');
}
const source = sourceFile ?? findPickerSource();
const cache = cacheDir ?? path.join(os.homedir(), '.cache', 'screencast');
const binary = path.join(cache, 'screencast-picker');
// Skip if binary exists and is newer than source
if (fs.existsSync(binary)) {
const srcStat = fs.statSync(source);
const binStat = fs.statSync(binary);
if (binStat.mtimeMs >= srcStat.mtimeMs) return binary;
}
fs.mkdirSync(cache, { recursive: true });
const result = spawnSync('xcrun', [
'swiftc', '-O', '-o', binary, source,
'-framework', 'AppKit', '-framework', 'CoreGraphics',
], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60000 });
if (result.error?.code === 'ENOENT') {
throw new Error(
'Xcode Command Line Tools required. Run: xcode-select --install'
);
}
if (result.error) {
throw new Error(`Failed to compile picker: ${result.error.message}`);
}
if (result.status !== 0) {
const stderr = (result.stderr || '').trim();
throw new Error(`Failed to compile picker: ${stderr}`);
}
return binary;
}
/**
* Run the picker in the given mode and map exit codes to JSON.
* @param {'window'|'region'} pickerMode
*/
function runPicker(pickerMode) {
if (os.platform() !== 'darwin') {
die('Interactive selection is only available on macOS');
}
let binary;
try {
binary = compilePicker();
} catch (err) {
die(err.message);
}
const result = spawnSync(binary, ['--mode', pickerMode], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 70000, // 10s buffer beyond the 60s picker timeout
});
if (result.error) {
die(result.error.code === 'ETIMEDOUT'
? 'Picker timed out'
: `Picker failed: ${result.error.message}`);
}
if (result.status === 0) {
let output;
try {
output = JSON.parse(result.stdout.trim());
} catch {
die(`Picker returned invalid output: ${(result.stdout || '').slice(0, 200)}`);
}
json(output);
} else if (result.status === 1) {
json({ cancelled: true });
} else if (result.status === 2) {
die('Picker timed out');
} else if (result.status === 3) {
die('Screen recording permission required. '
+ 'Check System Settings > Privacy & Security > Screen & System Audio Recording.');
} else {
const stderr = (result.stderr || '').trim();
die(`Picker failed (exit ${result.status}): ${stderr}`);
}
}
function cmdPickWindow() {
runPicker('window');
}
function cmdPickRegion() {
runPicker('region');
}
function cmdStart(flags) {
// Refuse if already recording
const existing = readState();
if (existing && isAlive(existing.pid)) {
die(`Already recording (pid ${existing.pid}). Run stop first.`);
}
const { backend, ffmpeg } = detectPlatform();
if (!ffmpeg) die('ffmpeg not found. Install via: brew install ffmpeg');
// Generate output filename if not provided
const ts = new Date().toISOString().replace(/[-:.T]/g, '').slice(0, 15);
const output = flags.output ?? path.join(process.cwd(), `screencast_${ts}.mp4`);
// Resolve window geometry if --window flag provided
let resolvedRegion = flags.region ?? null;
let screenSize = '1920x1080';
// Auto-detect screen size on Linux to avoid silently cropping non-1080p displays
if (backend === 'x11grab' && !flags.window && !flags.region) {
try {
const xr = execSync('xrandr --query', { encoding: 'utf8' });
const m = xr.match(/^(\S+)\s+connected.*?(\d+)x(\d+)\+\d+\+\d+/m);
if (m) screenSize = `${m[2]}x${m[3]}`;
} catch { /* fallback to 1920x1080 */ }
}
if (flags.window) {
try {
const geo = resolveWindowGeometry(flags.window);
resolvedRegion = `${geo.x},${geo.y},${geo.w},${geo.h}`;
screenSize = `${geo.w}x${geo.h}`;
} catch (err) {
die(`Cannot resolve window geometry for "${flags.window}": ${err.message}`);
}
}
// Resolve avfoundation device index dynamically
let screenInput = String(flags.screen);
if (backend === 'avfoundation') {
screenInput = String(resolveAvfoundationIndex(flags.screen));
}
const ffmpegArgs = buildFfmpegArgs({
backend,
screenInput,
fps: flags.fps,
region: resolvedRegion,
output,
screenSize,
display: process.env.DISPLAY ?? ':0.0',
});
// Open log file and spawn ffmpeg detached
const logFile = output.replace(/\.mp4$/, '.log');
const logFd = fs.openSync(logFile, 'w');
const child = spawn('ffmpeg', ffmpegArgs, {
detached: true,
stdio: ['pipe', logFd, logFd],
});
child.stdin.end();
child.unref();
const state = {
pid: child.pid,
output,
logFile,
started: Date.now(),
target: flags.window ? `window:${flags.window}` : `screen:${flags.screen}`,
};
writeState(state);
json({ recording: true, pid: child.pid, output, logFile, target: state.target });
}
function cmdStop() {
const state = readState();
if (!state) die('No recording in progress.');
if (!isAlive(state.pid)) {
clearState();
die('Recording process already dead (state cleared).');
}
const platform = os.platform();
if (platform === 'win32') {
try {
execSync(`taskkill /PID ${state.pid} /F`, { stdio: 'ignore' });
} catch {
// ignore
}
} else {
process.kill(state.pid, 'SIGINT');
}
// Poll for exit, max 5s, then escalate to SIGTERM
// Use Atomics.wait for cross-platform synchronous sleep (no POSIX sleep dependency)
const sab = new SharedArrayBuffer(4);
const i32 = new Int32Array(sab);
const deadline = Date.now() + 5000;
while (isAlive(state.pid) && Date.now() < deadline) {
Atomics.wait(i32, 0, 0, 200);
}
if (isAlive(state.pid)) {
process.kill(state.pid, 'SIGTERM');
Atomics.wait(i32, 0, 0, 1000);
}
clearState();
// Gather file stats
let duration = null;
let size = null;
try {
const probe = execSync(
`ffprobe -v error -show_entries format=duration -of csv=p=0 "${state.output}"`,
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
);
duration = parseFloat(probe.trim());
} catch {
// ffprobe unavailable or file not finalized
}
try {
size = fs.statSync(state.output).size;
} catch {
// file not found
}
json({ recording: false, output: state.output, duration, size });
}
function cmdStatus() {
const state = readState();
if (!state) {
json({ recording: false });
return;
}
if (!isAlive(state.pid)) {
clearState();
json({ recording: false, stale: true });
return;
}
const elapsed = Math.round((Date.now() - state.started) / 1000);
json({ recording: true, pid: state.pid, elapsed, output: state.output, target: state.target });
}
// ─── Main ─────────────────────────────────────────────────────────────────────
function main() {
const opts = parseArgs(process.argv);
switch (opts.command) {
case 'deps': cmdDeps(); break;
case 'list-windows': cmdListWindows(); break;
case 'list-screens': cmdListScreens(); break;
case 'pick-window': cmdPickWindow(); break;
case 'pick-region': cmdPickRegion(); break;
case 'start': cmdStart(opts); break;
case 'stop': cmdStop(); break;
case 'status': cmdStatus(); break;
default:
process.stderr.write([
'Usage: screencast.js <command> [flags]',
'',
'Commands:',
' deps Check dependencies',
' list-screens List available screens',
' list-windows List capturable windows',
' start [flags] Start recording',
' --fps N Frame rate (default: 30)',
' --screen N Screen index (default: 0)',
' --region X,Y,W,H Capture region',
' --window NAME Capture specific window',
' --output PATH Output file (default: auto-named .mp4)',
' stop Stop recording',
' status Show recording status',
' pick-window Click to select a window (macOS only)',
' pick-region Drag to select a region (macOS only)',
].join('\n') + '\n');
process.exit(opts.command ? 1 : 0);
}
}
if (require.main === module) {
main();
} else {
module.exports = {
parseArgs, detectPlatform, buildFfmpegArgs,
readState, writeState, clearState, isAlive,
resolveWindowGeometry, compilePicker,
};
}