From 2a4ccaf993dc98e5640cb89d6a8ad3caa2f5fce1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 18 Dec 2025 23:32:36 +0100 Subject: [PATCH] CLI: add nodes canvas snapshot + duration parsing --- src/cli/nodes-canvas.test.ts | 20 ++++++ src/cli/nodes-canvas.ts | 41 ++++++++++++ src/cli/nodes-cli.ts | 114 +++++++++++++++++++++++++++++++-- src/cli/parse-duration.test.ts | 21 ++++++ src/cli/parse-duration.ts | 27 ++++++++ src/cli/program.test.ts | 87 ++++++++++++++++++++++++- 6 files changed, 301 insertions(+), 9 deletions(-) create mode 100644 src/cli/nodes-canvas.test.ts create mode 100644 src/cli/nodes-canvas.ts create mode 100644 src/cli/parse-duration.test.ts create mode 100644 src/cli/parse-duration.ts diff --git a/src/cli/nodes-canvas.test.ts b/src/cli/nodes-canvas.test.ts new file mode 100644 index 000000000..95c5569f5 --- /dev/null +++ b/src/cli/nodes-canvas.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { parseCanvasSnapshotPayload } from "./nodes-canvas.js"; + +describe("nodes canvas helpers", () => { + it("parses canvas.snapshot payload", () => { + expect( + parseCanvasSnapshotPayload({ format: "png", base64: "aGk=" }), + ).toEqual({ + format: "png", + base64: "aGk=", + }); + }); + + it("rejects invalid canvas.snapshot payload", () => { + expect(() => parseCanvasSnapshotPayload({ format: "png" })).toThrow( + /invalid canvas\.snapshot payload/i, + ); + }); +}); diff --git a/src/cli/nodes-canvas.ts b/src/cli/nodes-canvas.ts new file mode 100644 index 000000000..04e3c6547 --- /dev/null +++ b/src/cli/nodes-canvas.ts @@ -0,0 +1,41 @@ +import { randomUUID } from "node:crypto"; +import * as os from "node:os"; +import * as path from "node:path"; + +export type CanvasSnapshotPayload = { + format: string; + base64: string; +}; + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null + ? (value as Record) + : {}; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +export function parseCanvasSnapshotPayload( + value: unknown, +): CanvasSnapshotPayload { + const obj = asRecord(value); + const format = asString(obj.format); + const base64 = asString(obj.base64); + if (!format || !base64) { + throw new Error("invalid canvas.snapshot payload"); + } + return { format, base64 }; +} + +export function canvasSnapshotTempPath(opts: { + ext: string; + tmpDir?: string; + id?: string; +}) { + const tmpDir = opts.tmpDir ?? os.tmpdir(); + const id = opts.id ?? randomUUID(); + const ext = opts.ext.startsWith(".") ? opts.ext : `.${opts.ext}`; + return path.join(tmpDir, `clawdis-canvas-snapshot-${id}${ext}`); +} diff --git a/src/cli/nodes-cli.ts b/src/cli/nodes-cli.ts index dd233cd21..cac643480 100644 --- a/src/cli/nodes-cli.ts +++ b/src/cli/nodes-cli.ts @@ -8,6 +8,11 @@ import { parseCameraSnapPayload, writeBase64ToFile, } from "./nodes-camera.js"; +import { + canvasSnapshotTempPath, + parseCanvasSnapshotPayload, +} from "./nodes-canvas.js"; +import { parseDurationMs } from "./parse-duration.js"; type NodesRpcOpts = { url?: string; @@ -473,6 +478,100 @@ export function registerNodesCli(program: Command) { .command("camera") .description("Capture camera media from a paired node"); + const canvas = nodes + .command("canvas") + .description("Capture or render canvas content from a paired node"); + + nodesCallOpts( + canvas + .command("snapshot") + .description("Capture a canvas snapshot (prints MEDIA:)") + .requiredOption("--node ", "Node id, name, or IP") + .option("--format ", "Image format", "jpg") + .option("--max-width ", "Max width in px (optional)") + .option("--quality <0-1>", "JPEG quality (optional)") + .option( + "--invoke-timeout ", + "Node invoke timeout in ms (default 20000)", + "20000", + ) + .action(async (opts: NodesRpcOpts) => { + try { + const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); + const formatOpt = String(opts.format ?? "jpg") + .trim() + .toLowerCase(); + const formatForParams = + formatOpt === "jpg" + ? "jpeg" + : formatOpt === "jpeg" + ? "jpeg" + : "png"; + if (formatForParams !== "png" && formatForParams !== "jpeg") { + throw new Error( + `invalid format: ${String(opts.format)} (expected png|jpg|jpeg)`, + ); + } + + const maxWidth = opts.maxWidth + ? Number.parseInt(String(opts.maxWidth), 10) + : undefined; + const quality = opts.quality + ? Number.parseFloat(String(opts.quality)) + : undefined; + const timeoutMs = opts.invokeTimeout + ? Number.parseInt(String(opts.invokeTimeout), 10) + : undefined; + + const invokeParams: Record = { + nodeId, + command: "canvas.snapshot", + params: { + format: formatForParams, + maxWidth: Number.isFinite(maxWidth) ? maxWidth : undefined, + quality: Number.isFinite(quality) ? quality : 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 = parseCanvasSnapshotPayload(res.payload); + const filePath = canvasSnapshotTempPath({ + ext: payload.format === "jpeg" ? "jpg" : payload.format, + }); + await writeBase64ToFile(filePath, payload.base64); + + if (opts.json) { + defaultRuntime.log( + JSON.stringify( + { file: { path: filePath, format: payload.format } }, + null, + 2, + ), + ); + return; + } + defaultRuntime.log(`MEDIA:${filePath}`); + } catch (err) { + defaultRuntime.error(`nodes canvas snapshot failed: ${String(err)}`); + defaultRuntime.exit(1); + } + }), + { timeoutMs: 60_000 }, + ); + nodesCallOpts( camera .command("snap") @@ -582,21 +681,22 @@ export function registerNodesCli(program: Command) { ) .requiredOption("--node ", "Node id, name, or IP") .option("--facing ", "Camera facing", "front") - .option("--duration ", "Duration in ms (default 3000)", "3000") + .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 45000)", - "45000", + "Node invoke timeout in ms (default 90000)", + "90000", ) .action(async (opts: NodesRpcOpts & { audio?: boolean }) => { try { const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); const facing = parseFacing(String(opts.facing ?? "front")); - const durationMs = Number.parseInt( - String(opts.duration ?? "3000"), - 10, - ); + const durationMs = parseDurationMs(String(opts.duration ?? "3000")); const includeAudio = opts.audio !== false; const timeoutMs = opts.invokeTimeout ? Number.parseInt(String(opts.invokeTimeout), 10) diff --git a/src/cli/parse-duration.test.ts b/src/cli/parse-duration.test.ts new file mode 100644 index 000000000..5bedee82d --- /dev/null +++ b/src/cli/parse-duration.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { parseDurationMs } from "./parse-duration.js"; + +describe("parseDurationMs", () => { + it("parses bare ms", () => { + expect(parseDurationMs("10000")).toBe(10_000); + }); + + it("parses seconds suffix", () => { + expect(parseDurationMs("10s")).toBe(10_000); + }); + + it("parses minutes suffix", () => { + expect(parseDurationMs("1m")).toBe(60_000); + }); + + it("supports decimals", () => { + expect(parseDurationMs("0.5s")).toBe(500); + }); +}); diff --git a/src/cli/parse-duration.ts b/src/cli/parse-duration.ts new file mode 100644 index 000000000..da115ac23 --- /dev/null +++ b/src/cli/parse-duration.ts @@ -0,0 +1,27 @@ +export type DurationMsParseOptions = { + defaultUnit?: "ms" | "s" | "m"; +}; + +export function parseDurationMs( + raw: string, + opts?: DurationMsParseOptions, +): number { + const trimmed = String(raw ?? "") + .trim() + .toLowerCase(); + if (!trimmed) throw new Error("invalid duration (empty)"); + + const m = /^(\d+(?:\.\d+)?)(ms|s|m)?$/.exec(trimmed); + if (!m) throw new Error(`invalid duration: ${raw}`); + + const value = Number(m[1]); + if (!Number.isFinite(value) || value < 0) { + throw new Error(`invalid duration: ${raw}`); + } + + const unit = (m[2] ?? opts?.defaultUnit ?? "ms") as "ms" | "s" | "m"; + const multiplier = unit === "ms" ? 1 : unit === "s" ? 1000 : 60_000; + const ms = Math.round(value * multiplier); + if (!Number.isFinite(ms)) throw new Error(`invalid duration: ${raw}`); + return ms; +} diff --git a/src/cli/program.test.ts b/src/cli/program.test.ts index 21bd39c06..98888f4c8 100644 --- a/src/cli/program.test.ts +++ b/src/cli/program.test.ts @@ -367,7 +367,7 @@ describe("cli program", () => { params: expect.objectContaining({ nodeId: "ios-node", command: "camera.clip", - timeoutMs: 45000, + timeoutMs: 90000, idempotencyKey: "idem-test", params: expect.objectContaining({ facing: "front", @@ -505,7 +505,7 @@ describe("cli program", () => { params: expect.objectContaining({ nodeId: "ios-node", command: "camera.clip", - timeoutMs: 45000, + timeoutMs: 90000, idempotencyKey: "idem-test", params: expect.objectContaining({ includeAudio: false, @@ -524,6 +524,89 @@ describe("cli program", () => { } }); + it("runs nodes camera clip with human duration (10s)", async () => { + callGateway + .mockResolvedValueOnce({ + ts: Date.now(), + nodes: [ + { + nodeId: "ios-node", + displayName: "iOS Node", + remoteIp: "192.168.0.88", + connected: true, + }, + ], + }) + .mockResolvedValueOnce({ + ok: true, + nodeId: "ios-node", + command: "camera.clip", + payload: { + format: "mp4", + base64: "aGk=", + durationMs: 10_000, + hasAudio: true, + }, + }); + + const program = buildProgram(); + runtime.log.mockClear(); + await program.parseAsync( + ["nodes", "camera", "clip", "--node", "ios-node", "--duration", "10s"], + { from: "user" }, + ); + + expect(callGateway).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + method: "node.invoke", + params: expect.objectContaining({ + nodeId: "ios-node", + command: "camera.clip", + params: expect.objectContaining({ durationMs: 10_000 }), + }), + }), + ); + }); + + it("runs nodes canvas snapshot and prints MEDIA path", async () => { + callGateway + .mockResolvedValueOnce({ + ts: Date.now(), + nodes: [ + { + nodeId: "ios-node", + displayName: "iOS Node", + remoteIp: "192.168.0.88", + connected: true, + }, + ], + }) + .mockResolvedValueOnce({ + ok: true, + nodeId: "ios-node", + command: "canvas.snapshot", + payload: { format: "png", base64: "aGk=" }, + }); + + const program = buildProgram(); + runtime.log.mockClear(); + await program.parseAsync( + ["nodes", "canvas", "snapshot", "--node", "ios-node", "--format", "png"], + { from: "user" }, + ); + + const out = String(runtime.log.mock.calls[0]?.[0] ?? ""); + const mediaPath = out.replace(/^MEDIA:/, "").trim(); + expect(mediaPath).toMatch(/clawdis-canvas-snapshot-.*\.png$/); + + try { + await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("hi"); + } finally { + await fs.unlink(mediaPath).catch(() => {}); + } + }); + it("fails nodes camera snap on invalid facing", async () => { callGateway.mockResolvedValueOnce({ ts: Date.now(),