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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,29 @@ 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.9.0]

### Changed

- **Tools no longer silently overwrite an existing file at a caller-supplied
`output` path** (#37). Every tool with an `output` parameter now refuses an
existing path with a clear error unless the new optional `overwrite: true`
is passed. Auto-generated default paths (unique names under
`SCREENCAST_HOME`) are unaffected.

### Fixed

- **`extract_audio` with `format: "copy"` picks a container that matches the
source codec** (#36). The blanket `.m4a` broke for opus/vorbis/etc; the
source is now probed and mapped (aac/alac → m4a, mp3 → mp3, opus/vorbis →
ogg, flac → flac, pcm → wav, anything else → mka), and an input with no
audio stream errors up front. The response includes `sourceAudioCodec`.
- **Odd output dimensions are rejected with a clear error in the re-encode
tools** (#47). `scale`, `crop`, `xfade_transition`, `assemble_highlights`,
and `title_card` now reject an explicit odd `width`/`height` (H.264 +
yuv420p cannot encode them) instead of failing with a cryptic
"not divisible by 2" encoder tail.

## [0.8.13]

### Fixed
Expand Down
57 changes: 38 additions & 19 deletions mcp-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
},
"required": ["source"]
},
"output": { "type": "string", "description": "Optional output .mp4 path (defaults under SCREENCAST_HOME/recordings)" }
"output": { "type": "string", "description": "Optional output .mp4 path (defaults under SCREENCAST_HOME/recordings)" },
"overwrite": { "type": "boolean", "description": "Allow replacing an existing file at the output path (default false)" }
},
"required": ["target"]
}
Expand Down Expand Up @@ -56,7 +57,8 @@
"type": "object",
"properties": {
"target": { "type": "string", "description": "full | monitor:<index> | window:<title> | region:<x>,<y>,<w>,<h>. window: captures the rectangle the window occupies as displayed (case-insensitive exact title, else substring; topmost match)" },
"output": { "type": "string", "description": "Optional output .png path (defaults under SCREENCAST_HOME/screenshots)" }
"output": { "type": "string", "description": "Optional output .png path (defaults under SCREENCAST_HOME/screenshots)" },
"overwrite": { "type": "boolean", "description": "Allow replacing an existing file at the output path (default false)" }
},
"required": ["target"]
}
Expand Down Expand Up @@ -96,7 +98,8 @@
"start": { "type": "number", "description": "Start time in seconds" },
"end": { "type": "number", "description": "End time in seconds (use end OR duration)" },
"duration": { "type": "number", "description": "Clip length in seconds (use end OR duration)" },
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" }
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" },
"overwrite": { "type": "boolean", "description": "Allow replacing an existing file at the output path (default false)" }
},
"required": ["input", "start"]
}
Expand All @@ -108,7 +111,8 @@
"type": "object",
"properties": {
"inputs": { "type": "array", "items": { "type": "string" }, "minItems": 2, "description": "Two or more video paths to join, in order" },
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" }
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" },
"overwrite": { "type": "boolean", "description": "Allow replacing an existing file at the output path (default false)" }
},
"required": ["inputs"]
}
Expand All @@ -123,7 +127,8 @@
"format": { "type": "string", "enum": ["mp4", "gif", "webm"], "description": "Target format" },
"fps": { "type": "number", "description": "Output fps (gif only; default 12)" },
"width": { "type": "number", "description": "Output width in px, height auto (gif only; default 720)" },
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" }
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" },
"overwrite": { "type": "boolean", "description": "Allow replacing an existing file at the output path (default false)" }
},
"required": ["input", "format"]
}
Expand All @@ -139,7 +144,8 @@
"y": { "type": "integer", "minimum": 0, "description": "Top edge of the crop in pixels" },
"width": { "type": "integer", "minimum": 1, "description": "Crop width in pixels" },
"height": { "type": "integer", "minimum": 1, "description": "Crop height in pixels" },
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" }
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" },
"overwrite": { "type": "boolean", "description": "Allow replacing an existing file at the output path (default false)" }
},
"required": ["input", "x", "y", "width", "height"]
}
Expand All @@ -153,7 +159,8 @@
"input": { "type": "string", "description": "Path to the source video" },
"width": { "type": "integer", "minimum": 1, "description": "Target width in px (omit to derive from height)" },
"height": { "type": "integer", "minimum": 1, "description": "Target height in px (omit to derive from width)" },
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" }
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" },
"overwrite": { "type": "boolean", "description": "Allow replacing an existing file at the output path (default false)" }
},
"required": ["input"]
}
Expand All @@ -166,7 +173,8 @@
"properties": {
"input": { "type": "string", "description": "Path to the source video" },
"factor": { "type": "number", "exclusiveMinimum": 0, "description": "Speed multiplier: >1 faster, <1 slower (2 = double speed)" },
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" }
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" },
"overwrite": { "type": "boolean", "description": "Allow replacing an existing file at the output path (default false)" }
},
"required": ["input", "factor"]
}
Expand All @@ -185,7 +193,8 @@
"height": { "type": "integer", "minimum": 1, "description": "Optional overlay height in px (width auto)" },
"start": { "type": "number", "minimum": 0, "description": "Optional second to show the overlay from" },
"end": { "type": "number", "exclusiveMinimum": 0, "description": "Optional second to hide the overlay after" },
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" }
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" },
"overwrite": { "type": "boolean", "description": "Allow replacing an existing file at the output path (default false)" }
},
"required": ["input", "overlay", "x", "y"]
}
Expand All @@ -199,7 +208,8 @@
"input": { "type": "string", "description": "Path to the source video" },
"level": { "type": "string", "enum": ["light", "medium", "heavy"], "description": "Compression strength (CRF 23 / 28 / 32; default medium)" },
"maxWidth": { "type": "integer", "minimum": 1, "description": "Optional width cap in px; only ever downscales" },
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" }
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" },
"overwrite": { "type": "boolean", "description": "Allow replacing an existing file at the output path (default false)" }
},
"required": ["input"]
}
Expand All @@ -212,7 +222,8 @@
"properties": {
"input": { "type": "string", "description": "Path to the source media" },
"format": { "type": "string", "enum": ["mp3", "aac", "wav", "copy"], "description": "Audio output codec; copy keeps the source codec without re-encoding" },
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" }
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" },
"overwrite": { "type": "boolean", "description": "Allow replacing an existing file at the output path (default false)" }
},
"required": ["input", "format"]
}
Expand All @@ -237,7 +248,8 @@
},
"description": "One or more {start, end} segments; each becomes its own output file"
},
"output": { "type": "string", "description": "Optional output path (single segment only; multi-segment lands under SCREENCAST_HOME/edits)" }
"output": { "type": "string", "description": "Optional output path (single segment only; multi-segment lands under SCREENCAST_HOME/edits)" },
"overwrite": { "type": "boolean", "description": "Allow replacing an existing file at the output path (default false)" }
},
"required": ["input", "segments"]
}
Expand Down Expand Up @@ -269,7 +281,8 @@
"style": { "type": "string", "enum": ["box", "blur", "pixelate"], "description": "box (default) draws an irreversible solid fill; blur and pixelate are softer but partially recoverable" },
"pad": { "type": "integer", "minimum": 0, "description": "Optional pixels to expand each region by, to cover anti-aliased edges (default 0)" },
"color": { "type": "string", "description": "Fill color for box style (default black)" },
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" }
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" },
"overwrite": { "type": "boolean", "description": "Allow replacing an existing file at the output path (default false)" }
},
"required": ["input", "regions"]
}
Expand All @@ -289,7 +302,8 @@
"inputB": { "type": "string", "description": "Path to the second (incoming) video" },
"transition": { "type": "string", "description": "xfade transition name (default fade): fade, wipeleft, slideup, circleopen, dissolve, ..." },
"duration": { "type": "number", "exclusiveMinimum": 0, "description": "Transition length in seconds (default 1)" },
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" }
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" },
"overwrite": { "type": "boolean", "description": "Allow replacing an existing file at the output path (default false)" }
},
"required": ["inputA", "inputB"]
}
Expand All @@ -306,7 +320,8 @@
"width": { "type": "integer", "minimum": 1, "description": "Common output width (default 1920)" },
"height": { "type": "integer", "minimum": 1, "description": "Common output height (default 1080)" },
"fps": { "type": "integer", "minimum": 1, "description": "Common output fps (default 30)" },
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" }
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" },
"overwrite": { "type": "boolean", "description": "Allow replacing an existing file at the output path (default false)" }
},
"required": ["clips"]
}
Expand All @@ -326,7 +341,8 @@
"fontColor": { "type": "string", "description": "Text color (default white)" },
"fontSize": { "type": "integer", "minimum": 1, "description": "Text size in px (default 96)" },
"bold": { "type": "boolean", "description": "Use the bold weight (default true)" },
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" }
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" },
"overwrite": { "type": "boolean", "description": "Allow replacing an existing file at the output path (default false)" }
},
"required": ["text"]
}
Expand All @@ -343,7 +359,8 @@
"fadeIn": { "type": "number", "minimum": 0, "description": "Music fade-in seconds (default 1)" },
"fadeOut": { "type": "number", "minimum": 0, "description": "Music fade-out seconds (default 2)" },
"duck": { "type": "boolean", "description": "Duck the music under the original audio via a sidechain (default false)" },
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" }
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" },
"overwrite": { "type": "boolean", "description": "Allow replacing an existing file at the output path (default false)" }
},
"required": ["video", "music"]
}
Expand All @@ -357,7 +374,8 @@
"input": { "type": "string", "description": "Path to the source video" },
"aspect": { "type": "string", "enum": ["16:9", "9:16", "1:1", "4:5"], "description": "Target aspect ratio" },
"fit": { "type": "string", "enum": ["pad", "crop"], "description": "pad (default): scale to fit and letterbox. crop: scale to fill and center-crop." },
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" }
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" },
"overwrite": { "type": "boolean", "description": "Allow replacing an existing file at the output path (default false)" }
},
"required": ["input", "aspect"]
}
Expand All @@ -371,7 +389,8 @@
"input": { "type": "string", "description": "Path to the source video" },
"platform": { "type": "string", "enum": ["youtube", "instagram_reel", "tiktok", "x", "square"], "description": "Target platform (sets aspect, resolution, fps, bitrate)" },
"fit": { "type": "string", "enum": ["pad", "crop"], "description": "How to fit the source into the platform aspect: pad (default) or crop" },
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" }
"output": { "type": "string", "description": "Optional output path (defaults under SCREENCAST_HOME/edits)" },
"overwrite": { "type": "boolean", "description": "Allow replacing an existing file at the output path (default false)" }
},
"required": ["input", "platform"]
}
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.13",
"version": "0.9.0",
"description": "MCP server for Windows screen recording, frame sampling, and minimal ffmpeg edits",
"type": "module",
"main": "dist/index.js",
Expand Down
26 changes: 26 additions & 0 deletions src/__tests__/media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
buildCompressArgs,
buildExtractAudioArgs,
buildClipArgs,
copyAudioExtension,
} from "../utils/media.js";

describe("parseFrameRate", () => {
Expand Down Expand Up @@ -168,6 +169,10 @@ describe("buildCropArgs", () => {
expect(() => buildCropArgs("i", "o", { x: 0, y: 0, width: 0, height: 50 })).toThrow();
expect(() => buildCropArgs("i", "o", { x: -1, y: 0, width: 10, height: 10 })).toThrow();
});
it("rejects odd dimensions with a clear error (yuv420p needs even)", () => {
expect(() => buildCropArgs("i", "o", { x: 0, y: 0, width: 101, height: 100 })).toThrow(/even/);
expect(() => buildCropArgs("i", "o", { x: 0, y: 0, width: 100, height: 101 })).toThrow(/even/);
});
});

describe("buildScaleArgs", () => {
Expand All @@ -178,6 +183,27 @@ describe("buildScaleArgs", () => {
it("requires at least one side", () => {
expect(() => buildScaleArgs("i", "o", {})).toThrow();
});
it("rejects an explicit odd dimension (the omitted side is safe via -2)", () => {
expect(() => buildScaleArgs("i", "o", { width: 101, height: 101 })).toThrow(/even/);
expect(() => buildScaleArgs("i", "o", { width: 101 })).toThrow(/even/);
expect(buildScaleArgs("i", "o", { width: 100 }).join(" ")).toContain("scale=100:-2");
});
});

describe("copyAudioExtension", () => {
it("maps each codec family to a container that can hold it", () => {
expect(copyAudioExtension("aac")).toBe("m4a");
expect(copyAudioExtension("alac")).toBe("m4a");
expect(copyAudioExtension("mp3")).toBe("mp3");
expect(copyAudioExtension("opus")).toBe("ogg");
expect(copyAudioExtension("vorbis")).toBe("ogg");
expect(copyAudioExtension("flac")).toBe("flac");
expect(copyAudioExtension("pcm_s16le")).toBe("wav");
});
it("falls back to Matroska audio for anything unrecognized", () => {
expect(copyAudioExtension("ac3")).toBe("mka");
expect(copyAudioExtension(null)).toBe("mka");
});
});

describe("atempoChain / buildSpeedArgs", () => {
Expand Down
25 changes: 24 additions & 1 deletion src/__tests__/paths.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, it, expect } from "vitest";
import { tmpdir } from "node:os";
import { tempPath } from "../utils/paths.js";
import { join } from "node:path";
import { writeFileSync, rmSync } from "node:fs";
import { tempPath, resolveOutput } from "../utils/paths.js";

describe("tempPath", () => {
it("returns a unique scratch path in the OS temp dir with the given suffix", () => {
Expand All @@ -15,3 +17,24 @@ describe("tempPath", () => {
expect(tempPath().startsWith(tmpdir())).toBe(true);
});
});

describe("resolveOutput overwrite guard", () => {
it("refuses a caller-supplied path that already exists", () => {
const existing = join(tmpdir(), `sc-out-${Date.now()}.mp4`);
writeFileSync(existing, "x");
try {
expect(() => resolveOutput(existing, tmpdir(), "d.mp4")).toThrow(/overwrite/);
expect(resolveOutput(existing, tmpdir(), "d.mp4", true)).toBe(existing);
} finally {
rmSync(existing, { force: true });
}
});
it("allows a caller-supplied path that does not exist yet", () => {
const fresh = join(tmpdir(), `sc-out-${Date.now()}-missing.mp4`);
expect(resolveOutput(fresh, tmpdir(), "d.mp4")).toBe(fresh);
});
it("never guards auto-generated default names", () => {
expect(resolveOutput(undefined, tmpdir(), "d.mp4")).toBe(join(tmpdir(), "d.mp4"));
expect(resolveOutput("", tmpdir(), "d.mp4")).toBe(join(tmpdir(), "d.mp4"));
});
});
Loading
Loading