CLI: add nodes canvas snapshot + duration parsing
parent
ac50a14b6a
commit
2a4ccaf993
|
|
@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,11 @@ import {
|
||||||
parseCameraSnapPayload,
|
parseCameraSnapPayload,
|
||||||
writeBase64ToFile,
|
writeBase64ToFile,
|
||||||
} from "./nodes-camera.js";
|
} from "./nodes-camera.js";
|
||||||
|
import {
|
||||||
|
canvasSnapshotTempPath,
|
||||||
|
parseCanvasSnapshotPayload,
|
||||||
|
} from "./nodes-canvas.js";
|
||||||
|
import { parseDurationMs } from "./parse-duration.js";
|
||||||
|
|
||||||
type NodesRpcOpts = {
|
type NodesRpcOpts = {
|
||||||
url?: string;
|
url?: string;
|
||||||
|
|
@ -473,6 +478,100 @@ export function registerNodesCli(program: Command) {
|
||||||
.command("camera")
|
.command("camera")
|
||||||
.description("Capture camera media from a paired node");
|
.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:<path>)")
|
||||||
|
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||||
|
.option("--format <png|jpg|jpeg>", "Image format", "jpg")
|
||||||
|
.option("--max-width <px>", "Max width in px (optional)")
|
||||||
|
.option("--quality <0-1>", "JPEG quality (optional)")
|
||||||
|
.option(
|
||||||
|
"--invoke-timeout <ms>",
|
||||||
|
"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<string, unknown> = {
|
||||||
|
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(
|
nodesCallOpts(
|
||||||
camera
|
camera
|
||||||
.command("snap")
|
.command("snap")
|
||||||
|
|
@ -582,21 +681,22 @@ export function registerNodesCli(program: Command) {
|
||||||
)
|
)
|
||||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||||
.option("--facing <front|back>", "Camera facing", "front")
|
.option("--facing <front|back>", "Camera facing", "front")
|
||||||
.option("--duration <ms>", "Duration in ms (default 3000)", "3000")
|
.option(
|
||||||
|
"--duration <ms|10s|1m>",
|
||||||
|
"Duration (default 3000ms; supports ms/s/m, e.g. 10s)",
|
||||||
|
"3000",
|
||||||
|
)
|
||||||
.option("--no-audio", "Disable audio capture")
|
.option("--no-audio", "Disable audio capture")
|
||||||
.option(
|
.option(
|
||||||
"--invoke-timeout <ms>",
|
"--invoke-timeout <ms>",
|
||||||
"Node invoke timeout in ms (default 45000)",
|
"Node invoke timeout in ms (default 90000)",
|
||||||
"45000",
|
"90000",
|
||||||
)
|
)
|
||||||
.action(async (opts: NodesRpcOpts & { audio?: boolean }) => {
|
.action(async (opts: NodesRpcOpts & { audio?: boolean }) => {
|
||||||
try {
|
try {
|
||||||
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
||||||
const facing = parseFacing(String(opts.facing ?? "front"));
|
const facing = parseFacing(String(opts.facing ?? "front"));
|
||||||
const durationMs = Number.parseInt(
|
const durationMs = parseDurationMs(String(opts.duration ?? "3000"));
|
||||||
String(opts.duration ?? "3000"),
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
const includeAudio = opts.audio !== false;
|
const includeAudio = opts.audio !== false;
|
||||||
const timeoutMs = opts.invokeTimeout
|
const timeoutMs = opts.invokeTimeout
|
||||||
? Number.parseInt(String(opts.invokeTimeout), 10)
|
? Number.parseInt(String(opts.invokeTimeout), 10)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -367,7 +367,7 @@ describe("cli program", () => {
|
||||||
params: expect.objectContaining({
|
params: expect.objectContaining({
|
||||||
nodeId: "ios-node",
|
nodeId: "ios-node",
|
||||||
command: "camera.clip",
|
command: "camera.clip",
|
||||||
timeoutMs: 45000,
|
timeoutMs: 90000,
|
||||||
idempotencyKey: "idem-test",
|
idempotencyKey: "idem-test",
|
||||||
params: expect.objectContaining({
|
params: expect.objectContaining({
|
||||||
facing: "front",
|
facing: "front",
|
||||||
|
|
@ -505,7 +505,7 @@ describe("cli program", () => {
|
||||||
params: expect.objectContaining({
|
params: expect.objectContaining({
|
||||||
nodeId: "ios-node",
|
nodeId: "ios-node",
|
||||||
command: "camera.clip",
|
command: "camera.clip",
|
||||||
timeoutMs: 45000,
|
timeoutMs: 90000,
|
||||||
idempotencyKey: "idem-test",
|
idempotencyKey: "idem-test",
|
||||||
params: expect.objectContaining({
|
params: expect.objectContaining({
|
||||||
includeAudio: false,
|
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 () => {
|
it("fails nodes camera snap on invalid facing", async () => {
|
||||||
callGateway.mockResolvedValueOnce({
|
callGateway.mockResolvedValueOnce({
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue