Spaces:
Configuration error
Configuration error
| 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 <idOrNameOrIp>", "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:<path>)") | |
| .requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP") | |
| .option("--facing <front|back|both>", "Camera facing", "both") | |
| .option("--device-id <id>", "Camera device id (from nodes camera list)") | |
| .option("--max-width <px>", "Max width in px (optional)") | |
| .option("--quality <0-1>", "JPEG quality (default 0.9)") | |
| .option("--delay-ms <ms>", "Delay before capture in ms (macOS default 2000)") | |
| .option("--invoke-timeout <ms>", "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<string, unknown> = { | |
| 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:<path>)") | |
| .requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP") | |
| .option("--facing <front|back>", "Camera facing", "front") | |
| .option("--device-id <id>", "Camera device id (from nodes camera list)") | |
| .option( | |
| "--duration <ms|10s|1m>", | |
| "Duration (default 3000ms; supports ms/s/m, e.g. 10s)", | |
| "3000", | |
| ) | |
| .option("--no-audio", "Disable audio capture") | |
| .option("--invoke-timeout <ms>", "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<string, unknown> = { | |
| 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 }, | |
| ); | |
| } | |