import type { Command } from "commander"; import { randomIdempotencyKey } from "../../gateway/call.js"; import { defaultRuntime } from "../../runtime.js"; import { type CameraFacing, cameraTempPath, parseCameraClipPayload, parseCameraSnapPayload, writeBase64ToFile, } from "../nodes-camera.js"; import { parseDurationMs } from "../parse-duration.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; import type { NodesRpcOpts } from "./types.js"; import { renderTable } from "../../terminal/table.js"; import { shortenHomePath } from "../../utils.js"; const parseFacing = (value: string): CameraFacing => { const v = String(value ?? "") .trim() .toLowerCase(); if (v === "front" || v === "back") return v; throw new Error(`invalid facing: ${value} (expected front|back)`); }; export function registerNodesCameraCommands(nodes: Command) { const camera = nodes.command("camera").description("Capture camera media from a paired node"); nodesCallOpts( camera .command("list") .description("List available cameras on a node") .requiredOption("--node ", "Node id, name, or IP") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("camera list", async () => { const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); const raw = (await callGatewayCli("node.invoke", opts, { nodeId, command: "camera.list", params: {}, idempotencyKey: randomIdempotencyKey(), })) as unknown; const res = typeof raw === "object" && raw !== null ? (raw as { payload?: unknown }) : {}; const payload = typeof res.payload === "object" && res.payload !== null ? (res.payload as { devices?: unknown }) : {}; const devices = Array.isArray(payload.devices) ? payload.devices : []; if (opts.json) { defaultRuntime.log(JSON.stringify(devices, null, 2)); return; } if (devices.length === 0) { const { muted } = getNodesTheme(); defaultRuntime.log(muted("No cameras reported.")); return; } const { heading, muted } = getNodesTheme(); const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); const rows = devices.map((device) => ({ Name: typeof device.name === "string" ? device.name : "Unknown Camera", Position: typeof device.position === "string" ? device.position : muted("unspecified"), ID: typeof device.id === "string" ? device.id : "", })); defaultRuntime.log(heading("Cameras")); defaultRuntime.log( renderTable({ width: tableWidth, columns: [ { key: "Name", header: "Name", minWidth: 14, flex: true }, { key: "Position", header: "Position", minWidth: 10 }, { key: "ID", header: "ID", minWidth: 10, flex: true }, ], rows, }).trimEnd(), ); }); }), { timeoutMs: 60_000 }, ); nodesCallOpts( camera .command("snap") .description("Capture a photo from a node camera (prints MEDIA:)") .requiredOption("--node ", "Node id, name, or IP") .option("--facing ", "Camera facing", "both") .option("--device-id ", "Camera device id (from nodes camera list)") .option("--max-width ", "Max width in px (optional)") .option("--quality <0-1>", "JPEG quality (default 0.9)") .option("--delay-ms ", "Delay before capture in ms (macOS default 2000)") .option("--invoke-timeout ", "Node invoke timeout in ms (default 20000)", "20000") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("camera snap", async () => { const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); const facingOpt = String(opts.facing ?? "both") .trim() .toLowerCase(); const facings: CameraFacing[] = facingOpt === "both" ? ["front", "back"] : facingOpt === "front" || facingOpt === "back" ? [facingOpt] : (() => { throw new Error( `invalid facing: ${String(opts.facing)} (expected front|back|both)`, ); })(); const maxWidth = opts.maxWidth ? Number.parseInt(String(opts.maxWidth), 10) : undefined; const quality = opts.quality ? Number.parseFloat(String(opts.quality)) : undefined; const delayMs = opts.delayMs ? Number.parseInt(String(opts.delayMs), 10) : undefined; const deviceId = opts.deviceId ? String(opts.deviceId).trim() : undefined; const timeoutMs = opts.invokeTimeout ? Number.parseInt(String(opts.invokeTimeout), 10) : undefined; const results: Array<{ facing: CameraFacing; path: string; width: number; height: number; }> = []; for (const facing of facings) { const invokeParams: Record = { nodeId, command: "camera.snap", params: { facing, maxWidth: Number.isFinite(maxWidth) ? maxWidth : undefined, quality: Number.isFinite(quality) ? quality : undefined, format: "jpg", delayMs: Number.isFinite(delayMs) ? delayMs : undefined, deviceId: deviceId || undefined, }, idempotencyKey: randomIdempotencyKey(), }; if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) { invokeParams.timeoutMs = timeoutMs; } const raw = (await callGatewayCli("node.invoke", opts, invokeParams)) as unknown; const res = typeof raw === "object" && raw !== null ? (raw as { payload?: unknown }) : {}; const payload = parseCameraSnapPayload(res.payload); const filePath = cameraTempPath({ kind: "snap", facing, ext: payload.format === "jpeg" ? "jpg" : payload.format, }); await writeBase64ToFile(filePath, payload.base64); results.push({ facing, path: filePath, width: payload.width, height: payload.height, }); } if (opts.json) { defaultRuntime.log(JSON.stringify({ files: results }, null, 2)); return; } defaultRuntime.log(results.map((r) => `MEDIA:${shortenHomePath(r.path)}`).join("\n")); }); }), { timeoutMs: 60_000 }, ); nodesCallOpts( camera .command("clip") .description("Capture a short video clip from a node camera (prints MEDIA:)") .requiredOption("--node ", "Node id, name, or IP") .option("--facing ", "Camera facing", "front") .option("--device-id ", "Camera device id (from nodes camera list)") .option( "--duration ", "Duration (default 3000ms; supports ms/s/m, e.g. 10s)", "3000", ) .option("--no-audio", "Disable audio capture") .option("--invoke-timeout ", "Node invoke timeout in ms (default 90000)", "90000") .action(async (opts: NodesRpcOpts & { audio?: boolean }) => { await runNodesCommand("camera clip", async () => { const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); const facing = parseFacing(String(opts.facing ?? "front")); const durationMs = parseDurationMs(String(opts.duration ?? "3000")); const includeAudio = opts.audio !== false; const timeoutMs = opts.invokeTimeout ? Number.parseInt(String(opts.invokeTimeout), 10) : undefined; const deviceId = opts.deviceId ? String(opts.deviceId).trim() : undefined; const invokeParams: Record = { nodeId, command: "camera.clip", params: { facing, durationMs: Number.isFinite(durationMs) ? durationMs : undefined, includeAudio, format: "mp4", deviceId: deviceId || undefined, }, idempotencyKey: randomIdempotencyKey(), }; if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) { invokeParams.timeoutMs = timeoutMs; } const raw = (await callGatewayCli("node.invoke", opts, invokeParams)) as unknown; const res = typeof raw === "object" && raw !== null ? (raw as { payload?: unknown }) : {}; const payload = parseCameraClipPayload(res.payload); const filePath = cameraTempPath({ kind: "clip", facing, ext: payload.format, }); await writeBase64ToFile(filePath, payload.base64); if (opts.json) { defaultRuntime.log( JSON.stringify( { file: { facing, path: filePath, durationMs: payload.durationMs, hasAudio: payload.hasAudio, }, }, null, 2, ), ); return; } defaultRuntime.log(`MEDIA:${shortenHomePath(filePath)}`); }); }), { timeoutMs: 90_000 }, ); }