Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@ project adheres to [Semantic Versioning](https://semver.org/).
- ESLint (flat config, typescript-eslint recommended) with a `lint` script, run
in CI (#23). Tooling only; not part of the published package.

## [0.8.12]

### Fixed

- **Off-desktop region offsets are rejected with a clear error** (#41). A
`region:` whose rectangle fell outside the virtual desktop (a negative offset
before the leftmost monitor, or one running past the right/bottom edge) was
accepted by `parseTarget` and then failed in gdigrab with a cryptic libav
error. The region is now validated against the live virtual-desktop bounds (the
bounding box of all monitors) before capture. Negative offsets remain valid
when a monitor sits left of or above the primary, since the check uses the real
desktop origin rather than a blanket `>= 0`.

## [0.8.11]

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tmhs/screencast-mcp",
"version": "0.8.11",
"version": "0.8.12",
"description": "MCP server for Windows screen recording, frame sampling, and minimal ffmpeg edits",
"type": "module",
"main": "dist/index.js",
Expand Down
43 changes: 43 additions & 0 deletions src/__tests__/targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
buildScreenshotArgs,
buildAudioInputArgs,
resolveMonitor,
virtualDesktopBounds,
validateRegionOnDesktop,
QUALITY_PRESETS,
} from "../utils/targets.js";
import type { Monitor } from "../utils/monitors.js";
Expand Down Expand Up @@ -102,6 +104,47 @@ describe("resolveMonitor", () => {
});
});

describe("virtualDesktopBounds", () => {
it("is the bounding box of all monitors", () => {
expect(virtualDesktopBounds(MONITORS)).toEqual({ x: 0, y: 0, w: 4480, h: 1440 });
});
it("returns a zero box with no monitors", () => {
expect(virtualDesktopBounds([])).toEqual({ x: 0, y: 0, w: 0, h: 0 });
});
});

describe("validateRegionOnDesktop", () => {
it("accepts a region inside the desktop", () => {
expect(() =>
validateRegionOnDesktop({ x: 100, y: 50, w: 800, h: 600 }, MONITORS),
).not.toThrow();
});
it("rejects a negative offset before the leftmost monitor", () => {
expect(() =>
validateRegionOnDesktop({ x: -100, y: 0, w: 300, h: 200 }, MONITORS),
).toThrow(/outside the virtual desktop/);
});
it("rejects a rectangle running past the right edge", () => {
expect(() =>
validateRegionOnDesktop({ x: 4400, y: 0, w: 200, h: 200 }, MONITORS),
).toThrow(/outside the virtual desktop/);
});
it("accepts a legitimate negative offset when a monitor sits left of the primary", () => {
const withLeft: Monitor[] = [
{ index: 0, x: 0, y: 0, width: 1920, height: 1080, primary: true },
{ index: 1, x: -1920, y: 0, width: 1920, height: 1080, primary: false },
];
expect(() =>
validateRegionOnDesktop({ x: -100, y: 0, w: 300, h: 200 }, withLeft),
).not.toThrow();
});
it("skips validation when no monitor geometry is available", () => {
expect(() =>
validateRegionOnDesktop({ x: -9999, y: -9999, w: 10, h: 10 }, []),
).not.toThrow();
});
});

describe("buildCaptureArgs", () => {
it("captures the full desktop with gdigrab and fragmented mp4", () => {
const args = buildCaptureArgs({ kind: "full" }, { output: "out.mp4" });
Expand Down
9 changes: 8 additions & 1 deletion src/utils/resolveTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* surface for GPU-composited windows). The rect is resolved once, here.
* - full / region -> passed through unchanged.
*/
import { parseTarget, type Target } from "./targets.js";
import { parseTarget, validateRegionOnDesktop, type Target } from "./targets.js";
import { getMonitors, type Monitor } from "./monitors.js";
import { resolveWindowBounds, type WindowBounds } from "./windows.js";

Expand All @@ -35,5 +35,12 @@ export function resolveCaptureTarget(spec: string): ResolvedTarget {
if (parsed.kind === "monitor") {
return { target: parsed, monitors: getMonitors() };
}
if (parsed.kind === "region") {
// A window resolves to a region already clamped to the desktop; a raw
// region does not, so validate it against the live desktop bounds before
// gdigrab fails cryptically on an off-desktop rectangle.
validateRegionOnDesktop(parsed, getMonitors());
return { target: parsed, monitors: [] };
}
return { target: parsed, monitors: [] };
}
44 changes: 44 additions & 0 deletions src/utils/targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,50 @@ export function resolveMonitor(index: number, monitors: Monitor[]): Monitor {
return m;
}

/** The virtual desktop = the bounding box of every monitor. gdigrab captures
* this rectangle, so a region must fit inside it. Returns a zero-size box when
* no monitors are supplied (the caller then skips bounds validation). */
export function virtualDesktopBounds(
monitors: Monitor[],
): { x: number; y: number; w: number; h: number } {
if (monitors.length === 0) return { x: 0, y: 0, w: 0, h: 0 };
const minX = Math.min(...monitors.map((m) => m.x));
const minY = Math.min(...monitors.map((m) => m.y));
const maxX = Math.max(...monitors.map((m) => m.x + m.width));
const maxY = Math.max(...monitors.map((m) => m.y + m.height));
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}

/**
* Reject a region rectangle that is not fully inside the virtual desktop.
* gdigrab passes the offset/size straight through, so an off-desktop rectangle
* (a negative offset before the leftmost monitor, or one running past the
* right/bottom edge) otherwise fails with a cryptic libav error. Negative
* offsets are legitimate when a monitor sits left of / above the primary, so
* the rectangle is checked against the real desktop origin, not a blanket >= 0.
* With no monitor geometry the check is skipped (cannot validate).
*/
export function validateRegionOnDesktop(
region: { x: number; y: number; w: number; h: number },
monitors: Monitor[],
): void {
if (monitors.length === 0) return;
const v = virtualDesktopBounds(monitors);
const within =
region.x >= v.x &&
region.y >= v.y &&
region.x + region.w <= v.x + v.w &&
region.y + region.h <= v.y + v.h;
if (!within) {
throw new ScreencastError(
`region ${region.x},${region.y},${region.w},${region.h} is outside the ` +
`virtual desktop (origin ${v.x},${v.y}, size ${v.w}x${v.h}). gdigrab ` +
`captures the desktop, so the rectangle must fit inside it. Adjust the ` +
`offset/size, or use monitor:<index> to capture a whole display.`,
);
}
}

/** Encoder args for a quality preset (libx264, web-safe pixel format). */
export function resolveQuality(quality: Quality): string[] {
const q = QUALITY_PRESETS[quality];
Expand Down
Loading