mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
2251c7cf46
## What Adds a `screen-recording-start` / `screen-recording-stop` tool pair (naming follows `native-profiler-start/stop`) that records the device screen to an mp4 and returns it as a downloadable artifact. Capture runs through **simulator-server** — the same backend `screenshot` and every interaction tool already drive. simulator-server publishes the device screen as an MJPEG stream (`/stream.mjpeg`), so recording needs no platform-specific recorder: the tool-server subscribes to that stream, paces the frames onto a fixed 30 fps timeline, and pipes them into a single ffmpeg process that encodes — and watermarks — straight to the final mp4. The stream is multi-subscriber, so a preview window can stay open on the same device while recording. - **One path for every device simulator-server drives** — iOS simulators, Android emulators, and physical Android devices. - **Native resolution, constant 30 fps** — h264, `+faststart`, no post-processing pass. - Session state lives in a per-device `ScreenRecordingSession` blueprint (mirrors the native-profiler session): time-limit cap with auto-finalize, unexpected-exit recovery (a capture that ended on its own is still retrievable via stop), always-startable state reset on stop, and dispose-on-shutdown cleanup through the registry. ## Why the frames are paced host-side A device only emits a frame when the screen **changes**. Pointing ffmpeg straight at the stream therefore produces a video whose length is the sum of the moments something moved — a still screen collapses to a fraction of a second — and an ffmpeg blocked on a silent socket does not answer a stop signal promptly either (measured: 12 s late). Re-emitting the last frame on a wall-clock schedule fixes both: **video duration always equals real elapsed time**, and stop is a stdin EOF that finalizes in ~60 ms. Duplicate frames cost almost nothing once encoded (a 10 s recording of a static screen is ~1 MB). ## Finishing pass folded into capture The Argent watermark (logo + `By @swmansion`, bottom-left, sized relative to the frame) is stamped by the same ffmpeg invocation that encodes the capture, so there is no second encode and no temp intermediate. Its colour is chosen **per pixel** from the background luma underneath it — the white logo over dark areas, a runtime-derived near-black copy over light areas — so it keeps maximum contrast against whatever is behind it. It sits at 20 % opacity and is opt-out via `argent disable video-watermark` (read when the recording starts). Frame geometry comes from the first JPEG's SOF header, so no `ffprobe` is needed. ffmpeg is now the recorder rather than an optional finisher, so a missing ffmpeg fails `screen-recording-start` up front with an install hint instead of silently degrading the output at stop time. ## Reminder plumbing While a recording is running — or ended on its own but was never retrieved — **every** tool result carries a `note` telling the agent the recording is still going and how to stop it. It rides the same `note` field as the update reminder but is per-call and unsuppressed; both notes compose when they coincide. The note text switches to "already ended, retrieve the file" once the cap fires or the capture dies. ## Skill New `argent-screen-recording` skill (+ routing stanza in `rules/argent.md`): documents the start → interact → stop workflow and instructs the agent to schedule its own reminder/wakeup for the expected end of the recording immediately after starting one (harness reminder tool if available, otherwise a background `sleep` whose completion notification pulls it back). Scores 10/10 on `scripts/grade-skills.mjs`. ## Verification - Unit: 45 tests across `screen-recording.test.ts`, `mjpeg-stream.test.ts`, `watermark.test.ts`, `http-recording-note.test.ts`; full tool-server suite **2808 tests / 280 files green**; `tsc --noEmit -p tsconfig.test.json`, root `tsc --build`, eslint, prettier all clean. - E2E on a live tool-server (`POST /tools/...`), driving real interactions between start and stop: - **iOS sim** (iPhone 16 Pro Max, iOS 18.5): 1320×2868 h264, `r_frame_rate=30/1`, 338 frames, container duration 11.27 s vs reported `durationMs` 11314 — **within 50 ms of wall clock**. Extracted frames show real screen content (mid app-switcher animation) with the watermark rendered light-over-dark. - **Android emulator** (Pixel 3a, API 34): 1080×2220 h264, 30 fps, 353 frames, duration 11.77 s vs `durationMs` 11793 — within 30 ms. 512 KB for a mostly-static home screen. - **Static screen** (no interaction at all, 10 s): 300 frames, duration exactly 10.000 s — the case the previous `recordVideo` implementation reduced to ~0.03 s. - **Long silence then activity** (15 s untouched, then an app launch): stream survived, no warning, frames after the silence show the new screen. This caught a real bug during review — the connect timeout was also acting as a socket *inactivity* timeout and tore the stream down at 10 s of stillness; fixed and covered by a regression test that fails without the fix. - **Time-limit cap**: `timeLimitSeconds: 5` self-finalized at 4.97 s / 149 frames, the per-call note flipped to "already ended (it hit its 5s time limit)", and a stop issued 11 s later still returned the video with the matching warning. ## Not supported (capability-gated with clear errors) tvOS simulators, physical iPhones, Chromium apps, Vega, and remote (`remote:`-prefixed) simulators — none of them expose a readable frame stream. --------- Co-authored-by: Ignacy Łątka <claude-hubert.gancarczyk@swmansion.com>
198 lines
7.2 KiB
TypeScript
198 lines
7.2 KiB
TypeScript
import { describe, it, expect, afterEach } from "vitest";
|
|
import http from "http";
|
|
import { getFailureSignal, FAILURE_CODES } from "@argent/registry";
|
|
import { openMjpegStream, type MjpegStream } from "../src/tools/screen-recording/mjpeg-stream";
|
|
|
|
/**
|
|
* These run against a real local HTTP server that reproduces simulator-server's
|
|
* framing exactly — including the quirk that the boundary follows the JPEG's
|
|
* last byte with no CRLF in between, which is what stops off-the-shelf
|
|
* multipart demuxers from reading this stream.
|
|
*/
|
|
|
|
const BOUNDARY = "--NextFrame\r\nContent-Type:image/jpeg\r\n\r\n";
|
|
|
|
function jpeg(marker: number, size = 32): Buffer {
|
|
const buf = Buffer.alloc(size, marker);
|
|
buf.writeUInt16BE(0xffd8, 0);
|
|
buf.writeUInt16BE(0xffd9, size - 2);
|
|
return buf;
|
|
}
|
|
|
|
let server: http.Server | null = null;
|
|
let stream: MjpegStream | null = null;
|
|
|
|
afterEach(async () => {
|
|
stream?.close();
|
|
stream = null;
|
|
if (server) {
|
|
const s = server;
|
|
server = null;
|
|
await new Promise<void>((resolve) => s.close(() => resolve()));
|
|
}
|
|
});
|
|
|
|
/** Serve a scripted sequence of raw chunks; returns the stream URL. */
|
|
async function serveChunks(
|
|
write: (res: http.ServerResponse) => void,
|
|
status = 200,
|
|
contentType = "multipart/x-mixed-replace;boundary=NextFrame"
|
|
): Promise<string> {
|
|
server = http.createServer((_req, res) => {
|
|
res.writeHead(status, { "content-type": contentType });
|
|
write(res);
|
|
});
|
|
await new Promise<void>((resolve) => server!.listen(0, "127.0.0.1", resolve));
|
|
const { port } = server!.address() as { port: number };
|
|
return `http://127.0.0.1:${port}/stream.mjpeg`;
|
|
}
|
|
|
|
async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (!predicate()) {
|
|
if (Date.now() > deadline) throw new Error("condition not met in time");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
}
|
|
}
|
|
|
|
describe("openMjpegStream", () => {
|
|
it("extracts frames delimited the way simulator-server writes them", async () => {
|
|
const first = jpeg(0xa1);
|
|
const second = jpeg(0xb2);
|
|
const url = await serveChunks((res) => {
|
|
res.write(Buffer.concat([Buffer.from(BOUNDARY), first, Buffer.from(BOUNDARY)]));
|
|
// Delayed so the two frames land in separate reads: the point of the test
|
|
// is that each one is decoded, and that `latest` tracks the newest.
|
|
setTimeout(() => res.write(Buffer.concat([second, Buffer.from(BOUNDARY)])), 25);
|
|
});
|
|
|
|
stream = await openMjpegStream(url);
|
|
const frame = await stream.waitForFirstFrame(2_000);
|
|
expect(frame.equals(first)).toBe(true);
|
|
|
|
await waitFor(() => stream!.frameCount >= 2);
|
|
expect(stream.latest!.equals(second)).toBe(true);
|
|
});
|
|
|
|
it("reassembles a frame split across chunk boundaries", async () => {
|
|
const frame = jpeg(0xc3, 64);
|
|
const url = await serveChunks((res) => {
|
|
res.write(Buffer.concat([Buffer.from(BOUNDARY), frame.subarray(0, 20)]));
|
|
setTimeout(() => res.write(frame.subarray(20)), 15);
|
|
setTimeout(() => res.write(Buffer.from(BOUNDARY)), 30);
|
|
});
|
|
|
|
stream = await openMjpegStream(url);
|
|
const received = await stream.waitForFirstFrame(2_000);
|
|
expect(received.equals(frame)).toBe(true);
|
|
});
|
|
|
|
it("holds an incomplete trailing frame back until its delimiter arrives", async () => {
|
|
const url = await serveChunks((res) => {
|
|
res.write(Buffer.concat([Buffer.from(BOUNDARY), jpeg(0xd4)]));
|
|
});
|
|
|
|
stream = await openMjpegStream(url);
|
|
await expect(stream.waitForFirstFrame(150)).rejects.toThrow(/No frame arrived/);
|
|
expect(stream.frameCount).toBe(0);
|
|
});
|
|
|
|
it("honours the boundary declared in the content-type header", async () => {
|
|
const frame = jpeg(0xe5);
|
|
const custom = "--OtherBoundary\r\nContent-Type:image/jpeg\r\n\r\n";
|
|
const url = await serveChunks(
|
|
(res) => {
|
|
res.write(Buffer.concat([Buffer.from(custom), frame, Buffer.from(custom)]));
|
|
},
|
|
200,
|
|
"multipart/x-mixed-replace;boundary=OtherBoundary"
|
|
);
|
|
|
|
stream = await openMjpegStream(url);
|
|
expect((await stream.waitForFirstFrame(2_000)).equals(frame)).toBe(true);
|
|
});
|
|
|
|
it("fails with a stream-unavailable signal on a non-200 response", async () => {
|
|
const url = await serveChunks((res) => res.end("nope"), 503, "text/plain");
|
|
|
|
try {
|
|
stream = await openMjpegStream(url);
|
|
expect.unreachable();
|
|
} catch (err) {
|
|
expect(getFailureSignal(err)?.error_code).toBe(
|
|
FAILURE_CODES.SCREEN_RECORDING_STREAM_UNAVAILABLE
|
|
);
|
|
expect((err as Error).message).toContain("503");
|
|
}
|
|
});
|
|
|
|
it("fails with a stream-unavailable signal when nothing is listening", async () => {
|
|
try {
|
|
// Port 1 on loopback refuses immediately.
|
|
stream = await openMjpegStream("http://127.0.0.1:1/stream.mjpeg");
|
|
expect.unreachable();
|
|
} catch (err) {
|
|
expect(getFailureSignal(err)?.error_code).toBe(
|
|
FAILURE_CODES.SCREEN_RECORDING_STREAM_UNAVAILABLE
|
|
);
|
|
}
|
|
});
|
|
|
|
it("survives a silent stretch longer than the connect timeout", async () => {
|
|
// A still screen emits no frames and therefore no bytes. The connect
|
|
// timeout must not double as an inactivity timeout, or recording a static
|
|
// screen would tear the stream down mid-capture.
|
|
const late = jpeg(0xa7);
|
|
const url = await serveChunks((res) => {
|
|
res.write(Buffer.concat([Buffer.from(BOUNDARY), jpeg(0x11), Buffer.from(BOUNDARY)]));
|
|
setTimeout(() => res.write(Buffer.concat([late, Buffer.from(BOUNDARY)])), 500);
|
|
});
|
|
|
|
stream = await openMjpegStream(url, 200);
|
|
await stream.waitForFirstFrame(1_000);
|
|
|
|
await waitFor(() => stream!.frameCount >= 2, 3_000);
|
|
expect(stream.error).toBeNull();
|
|
expect(stream.latest!.equals(late)).toBe(true);
|
|
});
|
|
|
|
it("rejects a pending waitForFirstFrame the moment the stream drops, not after the timeout", async () => {
|
|
// Headers land (stream resolves) but the socket dies before any complete
|
|
// frame. The pending first-frame waiter must reject on that drop, not block
|
|
// the full first-frame timeout and then report a misleading "no frame" one.
|
|
const url = await serveChunks((res) => {
|
|
res.write(Buffer.from(BOUNDARY)); // part header only, no complete frame
|
|
setTimeout(() => res.destroy(), 30);
|
|
});
|
|
|
|
stream = await openMjpegStream(url);
|
|
const started = Date.now();
|
|
let caught: unknown;
|
|
try {
|
|
await stream.waitForFirstFrame(5_000);
|
|
} catch (err) {
|
|
caught = err;
|
|
}
|
|
const elapsed = Date.now() - started;
|
|
|
|
expect(caught).toBeInstanceOf(Error);
|
|
expect(getFailureSignal(caught)?.error_code).toBe(
|
|
FAILURE_CODES.SCREEN_RECORDING_STREAM_UNAVAILABLE
|
|
);
|
|
// Surfaced promptly on the drop rather than blocking the 5s first-frame wait.
|
|
expect(elapsed).toBeLessThan(2_000);
|
|
});
|
|
|
|
it("records a mid-stream disconnect so stop can warn about it", async () => {
|
|
const url = await serveChunks((res) => {
|
|
res.write(Buffer.concat([Buffer.from(BOUNDARY), jpeg(0xf6), Buffer.from(BOUNDARY)]));
|
|
setTimeout(() => res.destroy(), 20);
|
|
});
|
|
|
|
stream = await openMjpegStream(url);
|
|
await stream.waitForFirstFrame(2_000);
|
|
await waitFor(() => stream!.error !== null);
|
|
expect(stream.error).toBeInstanceOf(Error);
|
|
});
|
|
});
|