From 65460e73e7c7e2bdee5eee2b0591a53a4cc2ab9e Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Fri, 3 Jul 2026 12:30:59 -0400 Subject: [PATCH 1/4] fix: reject odd output dimensions in re-encode tools with a clear error scale, crop, xfade_transition, assemble_highlights, and title_card let an explicit odd width/height reach libx264 + yuv420p, which fails with a cryptic "not divisible by 2" tail. A shared validateEvenDimension guard now rejects the value up front and suggests the nearest even sizes. Fixes #47. Co-Authored-By: Claude Fable 5 --- src/__tests__/media.test.ts | 26 +++++++++++++++++++++++++ src/__tests__/produce.test.ts | 18 ++++++++++++++++++ src/utils/media.ts | 36 ++++++++++++++++++++++++++++++----- src/utils/produce.ts | 29 +++++++++++++++++++++------- src/utils/validate.ts | 15 +++++++++++++++ 5 files changed, 112 insertions(+), 12 deletions(-) diff --git a/src/__tests__/media.test.ts b/src/__tests__/media.test.ts index e5d63da..74e4a39 100644 --- a/src/__tests__/media.test.ts +++ b/src/__tests__/media.test.ts @@ -18,6 +18,7 @@ import { buildCompressArgs, buildExtractAudioArgs, buildClipArgs, + copyAudioExtension, } from "../utils/media.js"; describe("parseFrameRate", () => { @@ -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", () => { @@ -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", () => { diff --git a/src/__tests__/produce.test.ts b/src/__tests__/produce.test.ts index 9f62a30..9e9675e 100644 --- a/src/__tests__/produce.test.ts +++ b/src/__tests__/produce.test.ts @@ -188,3 +188,21 @@ describe("buildExportPresetArgs / PLATFORM_PRESETS", () => { expect(() => buildExportPresetArgs("i", "o", "myspace")).toThrow(); }); }); + +describe("odd output dimensions are rejected (yuv420p needs even)", () => { + it("buildXfadeArgs", () => { + expect(() => buildXfadeArgs("a.mp4", "b.mp4", 5, "o.mp4", { width: 101 })).toThrow(/even/); + expect(() => buildXfadeArgs("a.mp4", "b.mp4", 5, "o.mp4", { height: 719 })).toThrow(/even/); + }); + it("buildAssembleArgs", () => { + expect(() => + buildAssembleArgs(["a.mp4", "b.mp4"], [5, 5], "o.mp4", { width: 1279 }), + ).toThrow(/even/); + }); + it("buildTitleCardArgs", () => { + expect(() => buildTitleCardArgs("t.txt", "f.ttf", "o.mp4", { height: 1079 })).toThrow(/even/); + }); + it("defaults remain accepted", () => { + expect(buildTitleCardArgs("t.txt", "f.ttf", "o.mp4").join(" ")).toContain("1920x1080"); + }); +}); diff --git a/src/utils/media.ts b/src/utils/media.ts index e8d60cc..c31ff86 100644 --- a/src/utils/media.ts +++ b/src/utils/media.ts @@ -6,7 +6,7 @@ */ import { ScreencastError } from "./errors.js"; import { resolveQuality, type Quality } from "./targets.js"; -import { validateColor } from "./validate.js"; +import { validateColor, validateEvenDimension } from "./validate.js"; /** Edit re-encodes default to the same preset as a standard capture. */ const DEFAULT_EDIT_QUALITY: Quality = "standard"; @@ -238,8 +238,8 @@ export function buildCropArgs( ): string[] { const x = requireNonNegativeInt(rect.x, "crop x"); const y = requireNonNegativeInt(rect.y, "crop y"); - const w = requirePositiveInt(rect.width, "crop width"); - const h = requirePositiveInt(rect.height, "crop height"); + const w = validateEvenDimension(requirePositiveInt(rect.width, "crop width"), "crop width"); + const h = validateEvenDimension(requirePositiveInt(rect.height, "crop height"), "crop height"); if (dims && dims.width != null && dims.height != null) { if (x + w > dims.width || y + h > dims.height) { throw new ScreencastError( @@ -268,8 +268,12 @@ export function buildScaleArgs( if (opts.width === undefined && opts.height === undefined) { throw new ScreencastError("scale requires width or height (or both)."); } - if (opts.width !== undefined) requirePositiveInt(opts.width, "scale width"); - if (opts.height !== undefined) requirePositiveInt(opts.height, "scale height"); + if (opts.width !== undefined) { + validateEvenDimension(requirePositiveInt(opts.width, "scale width"), "scale width"); + } + if (opts.height !== undefined) { + validateEvenDimension(requirePositiveInt(opts.height, "scale height"), "scale height"); + } const w = opts.width ?? -2; const h = opts.height ?? -2; return [ @@ -404,6 +408,28 @@ const AUDIO_CODEC: Record = { }; /** Strip video and write the audio track on its own (mp3 / aac / wav / copy). */ +/** Container extension that can hold a stream-copied audio codec. m4a (the old + * blanket choice) rejects opus/vorbis/etc at header-write time, leaving a + * broken file; Matroska audio (.mka) is the catch-all that accepts nearly any + * codec. Pure so the mapping is unit-tested. */ +export function copyAudioExtension(codec: string | null): string { + switch (codec) { + case "aac": + case "alac": + return "m4a"; + case "mp3": + return "mp3"; + case "opus": + case "vorbis": + return "ogg"; + case "flac": + return "flac"; + default: + if (codec && codec.startsWith("pcm_")) return "wav"; + return "mka"; + } +} + export function buildExtractAudioArgs( input: string, output: string, diff --git a/src/utils/produce.ts b/src/utils/produce.ts index 485d453..ca91edc 100644 --- a/src/utils/produce.ts +++ b/src/utils/produce.ts @@ -10,7 +10,11 @@ import { ScreencastError } from "./errors.js"; import { resolveQuality, type Quality } from "./targets.js"; import { escapeFilterPath } from "./fonts.js"; -import { validateTransition, validateColor } from "./validate.js"; +import { + validateTransition, + validateColor, + validateEvenDimension, +} from "./validate.js"; export const DEFAULT_PRODUCE_WIDTH = 1920; export const DEFAULT_PRODUCE_HEIGHT = 1080; @@ -31,6 +35,20 @@ function round3(n: number): number { return Math.round(n * 1000) / 1000; } +/** Output W/H for a produce builder: explicit values must be even (H.264 + + * yuv420p rejects odd dimensions); the defaults already are. */ +function producedDims(opts: { width?: number; height?: number }): { + w: number; + h: number; +} { + if (opts.width !== undefined) validateEvenDimension(opts.width, "width"); + if (opts.height !== undefined) validateEvenDimension(opts.height, "height"); + return { + w: opts.width ?? DEFAULT_PRODUCE_WIDTH, + h: opts.height ?? DEFAULT_PRODUCE_HEIGHT, + }; +} + /** Video normalization chain: fit inside WxH, letterbox, square pixels, fixed * fps, web-safe pixel format. Makes any clip compatible with concat / xfade. */ export function videoNormalizeChain(w: number, h: number, fps: number): string { @@ -79,8 +97,7 @@ export function buildXfadeArgs( opts: XfadeOptions = {}, hasAudio = false, ): string[] { - const w = opts.width ?? DEFAULT_PRODUCE_WIDTH; - const h = opts.height ?? DEFAULT_PRODUCE_HEIGHT; + const { w, h } = producedDims(opts); const fps = opts.fps ?? DEFAULT_PRODUCE_FPS; const rate = opts.audioRate ?? DEFAULT_AUDIO_RATE; const transition = validateTransition(opts.transition ?? "fade"); @@ -138,8 +155,7 @@ export function buildAssembleArgs( if (inputs.length < 2) { throw new ScreencastError("assemble_highlights requires at least two clips."); } - const w = opts.width ?? DEFAULT_PRODUCE_WIDTH; - const h = opts.height ?? DEFAULT_PRODUCE_HEIGHT; + const { w, h } = producedDims(opts); const fps = opts.fps ?? DEFAULT_PRODUCE_FPS; const rate = opts.audioRate ?? DEFAULT_AUDIO_RATE; const transition = opts.transition ?? "cut"; @@ -256,8 +272,7 @@ export function buildTitleCardArgs( output: string, opts: TitleCardOptions = {}, ): string[] { - const w = opts.width ?? DEFAULT_PRODUCE_WIDTH; - const h = opts.height ?? DEFAULT_PRODUCE_HEIGHT; + const { w, h } = producedDims(opts); const dur = opts.duration ?? 3; const fps = opts.fps ?? DEFAULT_PRODUCE_FPS; const bg = validateColor(opts.bg ?? "black", "bg"); diff --git a/src/utils/validate.ts b/src/utils/validate.ts index 3f7e40e..0008502 100644 --- a/src/utils/validate.ts +++ b/src/utils/validate.ts @@ -22,6 +22,21 @@ export function validateTransition(name: string): string { return name; } +/** Reject an odd output dimension with a clear message instead of letting + * libx264 + yuv420p fail with a cryptic "not divisible by 2" tail. Used by the + * re-encode builders where the caller asked for an exact size (scale, crop, + * xfade_transition, assemble_highlights, title_card); the capture path rounds + * down instead because there the size comes from screen geometry. */ +export function validateEvenDimension(value: number, label: string): number { + if (value % 2 !== 0) { + throw new ScreencastError( + `${label} must be even, got ${value}: H.264 output with yuv420p cannot ` + + `encode odd dimensions. Use ${value - 1} or ${value + 1}.`, + ); + } + return value; +} + // Characters that would break out of a filtergraph option value. const COLOR_META = /[:,;=[\]'"\\\s]/; From 46c919007af500b4a2a7775ddf3f65059adce422 Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Fri, 3 Jul 2026 12:31:00 -0400 Subject: [PATCH 2/4] fix: pick a container matching the source codec for extract_audio copy The blanket .m4a container cannot hold opus/vorbis/etc and wrote a broken file. copy now probes the source audio codec first, maps it to a compatible container (m4a/mp3/ogg/flac/wav, .mka as the catch-all), errors clearly when the input has no audio stream, and reports sourceAudioCodec in the result. Fixes #36. Co-Authored-By: Claude Fable 5 --- src/tools/extractAudio.ts | 49 ++++++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/src/tools/extractAudio.ts b/src/tools/extractAudio.ts index 5b8abb9..15dbbe8 100644 --- a/src/tools/extractAudio.ts +++ b/src/tools/extractAudio.ts @@ -2,16 +2,23 @@ import { z } from "zod"; import { existsSync } from "node:fs"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { errorResponse, okResponse, ScreencastError } from "../utils/errors.js"; -import { requireFfmpeg, runFfmpeg } from "../utils/ffmpeg.js"; -import { buildExtractAudioArgs, type AudioFormat } from "../utils/media.js"; +import { requireFfmpeg, runFfmpeg, runCapture } from "../utils/ffmpeg.js"; +import { + buildExtractAudioArgs, + buildProbeArgs, + parseMediaInfo, + copyAudioExtension, + type AudioFormat, +} from "../utils/media.js"; import { resolveOutput, subdir, stamp, rand } from "../utils/paths.js"; -// `copy` keeps the source codec; the .m4a container holds whatever was copied. -const EXTENSION: Record = { +// Re-encode formats have a fixed container. `copy` keeps the source codec, so +// its container must match that codec (probed per call, see below) - a blanket +// .m4a writes a broken file for opus/vorbis/etc (#36). +const EXTENSION: Record, string> = { mp3: "mp3", aac: "m4a", wav: "wav", - copy: "m4a", }; const inputSchema = { @@ -20,6 +27,7 @@ const inputSchema = { .enum(["mp3", "aac", "wav", "copy"]) .describe("Audio output codec. copy keeps the source codec without re-encoding."), output: z.string().optional().describe("Optional output path. Defaults under SCREENCAST_HOME/edits."), + overwrite: z.boolean().optional().describe("Allow replacing an existing file at the output path (default false)."), }; export function register(server: McpServer): void { @@ -30,18 +38,43 @@ export function register(server: McpServer): void { inputSchema, async (args) => { try { - requireFfmpeg(); + const { ffprobe } = requireFfmpeg(); if (!existsSync(args.input)) { throw new ScreencastError(`Input file not found: ${args.input}`); } const format = args.format as AudioFormat; + let audioCodec: string | null = null; + let ext: string; + if (format === "copy") { + // The copied codec dictates the container; probe it first. + const res = await runCapture(ffprobe, buildProbeArgs(args.input), 30_000); + if (res.code !== 0) { + throw new ScreencastError( + `ffprobe failed (exit ${res.code}): ${res.stderr.trim().slice(-400)}`, + ); + } + audioCodec = parseMediaInfo(JSON.parse(res.stdout)).audioCodec; + if (!audioCodec) { + throw new ScreencastError( + `Input has no audio stream to extract: ${args.input}`, + ); + } + ext = copyAudioExtension(audioCodec); + } else { + ext = EXTENSION[format]; + } const output = resolveOutput( args.output, subdir("edits"), - `audio-${stamp()}-${rand()}.${EXTENSION[format]}`, + `audio-${stamp()}-${rand()}.${ext}`, + args.overwrite, ); await runFfmpeg(buildExtractAudioArgs(args.input, output, format), 10 * 60_000); - return okResponse({ outputPath: output, format }); + return okResponse({ + outputPath: output, + format, + ...(audioCodec ? { sourceAudioCodec: audioCodec } : {}), + }); } catch (error) { return errorResponse(error); } From c8222800bf98c3a6dc5b721f2736fc74d12b2f1d Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Fri, 3 Jul 2026 12:31:03 -0400 Subject: [PATCH 3/4] feat: refuse to overwrite an existing output file unless overwrite: true Every ffmpeg builder runs with -y, so a caller-supplied output path that already existed was silently clobbered. resolveOutput now refuses an existing path with a clear error unless the tool call passes the new optional overwrite flag; unique auto-generated default paths are unaffected. Wired through all 19 tools with an output parameter and mirrored in mcp-tools.json. Closes #37. Co-Authored-By: Claude Fable 5 --- mcp-tools.json | 57 ++++++++++++++++++++++----------- src/__tests__/paths.test.ts | 25 ++++++++++++++- src/tools/assembleHighlights.ts | 2 ++ src/tools/clip.ts | 2 ++ src/tools/compress.ts | 2 ++ src/tools/concat.ts | 3 +- src/tools/convert.ts | 2 ++ src/tools/crop.ts | 2 ++ src/tools/exportPreset.ts | 3 +- src/tools/musicBed.ts | 3 +- src/tools/overlay.ts | 2 ++ src/tools/redactRegion.ts | 2 ++ src/tools/reframe.ts | 3 +- src/tools/scale.ts | 2 ++ src/tools/screenshot.ts | 2 ++ src/tools/speed.ts | 2 ++ src/tools/startRecording.ts | 2 ++ src/tools/titleCard.ts | 3 +- src/tools/trim.ts | 2 ++ src/tools/xfadeTransition.ts | 2 ++ src/utils/paths.ts | 20 ++++++++++-- 21 files changed, 115 insertions(+), 28 deletions(-) diff --git a/mcp-tools.json b/mcp-tools.json index c5b9455..186a427 100644 --- a/mcp-tools.json +++ b/mcp-tools.json @@ -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"] } @@ -56,7 +57,8 @@ "type": "object", "properties": { "target": { "type": "string", "description": "full | monitor: | window: | 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"] } @@ -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"] } @@ -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"] } @@ -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"] } @@ -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"] } @@ -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"] } @@ -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"] } @@ -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"] } @@ -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"] } @@ -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"] } @@ -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"] } @@ -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"] } @@ -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"] } @@ -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"] } @@ -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"] } @@ -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"] } @@ -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"] } @@ -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"] } diff --git a/src/__tests__/paths.test.ts b/src/__tests__/paths.test.ts index 62e2de8..5b50f04 100644 --- a/src/__tests__/paths.test.ts +++ b/src/__tests__/paths.test.ts @@ -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", () => { @@ -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")); + }); +}); diff --git a/src/tools/assembleHighlights.ts b/src/tools/assembleHighlights.ts index d865162..3a68f4e 100644 --- a/src/tools/assembleHighlights.ts +++ b/src/tools/assembleHighlights.ts @@ -24,6 +24,7 @@ const inputSchema = { height: z.number().int().positive().optional().describe("Common output height (default 1080)."), fps: z.number().int().positive().optional().describe("Common output fps (default 30)."), output: z.string().optional().describe("Optional output path. Defaults under SCREENCAST_HOME/edits."), + overwrite: z.boolean().optional().describe("Allow replacing an existing file at the output path (default false)."), }; export function register(server: McpServer): void { @@ -46,6 +47,7 @@ export function register(server: McpServer): void { args.output, subdir("edits"), `highlights-${stamp()}-${rand()}.mp4`, + args.overwrite, ); const ffArgs = buildAssembleArgs( args.clips, diff --git a/src/tools/clip.ts b/src/tools/clip.ts index 3a8e87b..d044297 100644 --- a/src/tools/clip.ts +++ b/src/tools/clip.ts @@ -22,6 +22,7 @@ const inputSchema = { .string() .optional() .describe("Optional output path. Only honored for a single segment; multi-segment output lands under SCREENCAST_HOME/edits."), + overwrite: z.boolean().optional().describe("Allow replacing an existing file at the output path (default false)."), }; export function register(server: McpServer): void { @@ -46,6 +47,7 @@ export function register(server: McpServer): void { single ? args.output : undefined, subdir("edits"), `clip-${stamp()}-${rand()}-${String(i).padStart(2, "0")}${ext}`, + args.overwrite, ); await runFfmpeg(buildClipArgs(args.input, output, seg), 10 * 60_000); outputs.push(output); diff --git a/src/tools/compress.ts b/src/tools/compress.ts index 20425b7..3e192d9 100644 --- a/src/tools/compress.ts +++ b/src/tools/compress.ts @@ -20,6 +20,7 @@ const inputSchema = { .optional() .describe("Optional width cap in px; only ever downscales. Height follows aspect."), output: z.string().optional().describe("Optional output path. Defaults under SCREENCAST_HOME/edits."), + overwrite: z.boolean().optional().describe("Allow replacing an existing file at the output path (default false)."), }; export function register(server: McpServer): void { @@ -39,6 +40,7 @@ export function register(server: McpServer): void { args.output, subdir("edits"), `compress-${stamp()}-${rand()}${ext}`, + args.overwrite, ); const ffArgs = buildCompressArgs(args.input, output, { level: args.level as CompressLevel | undefined, diff --git a/src/tools/concat.ts b/src/tools/concat.ts index 5a59d31..7f3825b 100644 --- a/src/tools/concat.ts +++ b/src/tools/concat.ts @@ -13,6 +13,7 @@ const inputSchema = { .min(2) .describe("Two or more video paths to join, in order. Inputs must share codec/format."), output: z.string().optional().describe("Optional output path. Defaults under SCREENCAST_HOME/edits."), + overwrite: z.boolean().optional().describe("Allow replacing an existing file at the output path (default false)."), }; export function register(server: McpServer): void { @@ -32,7 +33,7 @@ export function register(server: McpServer): void { if (!existsSync(f)) throw new ScreencastError(`Input file not found: ${f}`); } const ext = extname(inputs[0]) || ".mp4"; - const output = resolveOutput(args.output, subdir("edits"), `concat-${stamp()}-${rand()}${ext}`); + const output = resolveOutput(args.output, subdir("edits"), `concat-${stamp()}-${rand()}${ext}`, args.overwrite); const listFile = tempPath(".txt"); writeFileSync(listFile, buildConcatListContent(inputs)); try { diff --git a/src/tools/convert.ts b/src/tools/convert.ts index 326bb76..3ebc87c 100644 --- a/src/tools/convert.ts +++ b/src/tools/convert.ts @@ -14,6 +14,7 @@ const inputSchema = { fps: z.number().positive().optional().describe("Output fps (gif only; default 12)."), width: z.number().positive().optional().describe("Output width in px, height auto (gif only; default 720)."), output: z.string().optional().describe("Optional output path. Defaults under SCREENCAST_HOME/edits."), + overwrite: z.boolean().optional().describe("Allow replacing an existing file at the output path (default false)."), }; export function register(server: McpServer): void { @@ -33,6 +34,7 @@ export function register(server: McpServer): void { args.output, subdir("edits"), `convert-${stamp()}-${rand()}.${format}`, + args.overwrite, ); const ffArgs = buildConvertArgs(args.input, output, format, { fps: args.fps, diff --git a/src/tools/crop.ts b/src/tools/crop.ts index b26788c..9f42d78 100644 --- a/src/tools/crop.ts +++ b/src/tools/crop.ts @@ -14,6 +14,7 @@ const inputSchema = { width: z.number().int().positive().describe("Crop width in pixels."), height: z.number().int().positive().describe("Crop height in pixels."), output: z.string().optional().describe("Optional output path. Defaults under SCREENCAST_HOME/edits."), + overwrite: z.boolean().optional().describe("Allow replacing an existing file at the output path (default false)."), }; export function register(server: McpServer): void { @@ -34,6 +35,7 @@ export function register(server: McpServer): void { args.output, subdir("edits"), `crop-${stamp()}-${rand()}${ext}`, + args.overwrite, ); const ffArgs = buildCropArgs( args.input, diff --git a/src/tools/exportPreset.ts b/src/tools/exportPreset.ts index ff46432..43a2d8f 100644 --- a/src/tools/exportPreset.ts +++ b/src/tools/exportPreset.ts @@ -21,6 +21,7 @@ const inputSchema = { .optional() .describe("How to fit the source into the platform aspect: pad (default) or crop."), output: z.string().optional().describe("Optional output path. Defaults under SCREENCAST_HOME/edits."), + overwrite: z.boolean().optional().describe("Allow replacing an existing file at the output path (default false)."), }; export function register(server: McpServer): void { @@ -37,7 +38,7 @@ export function register(server: McpServer): void { throw new ScreencastError(`Input file not found: ${args.input}`); } const platform = args.platform as Platform; - const output = resolveOutput(args.output, subdir("edits"), `${platform}-${stamp()}-${rand()}.mp4`); + const output = resolveOutput(args.output, subdir("edits"), `${platform}-${stamp()}-${rand()}.mp4`, args.overwrite); const ffArgs = buildExportPresetArgs( args.input, output, diff --git a/src/tools/musicBed.ts b/src/tools/musicBed.ts index 3035a22..7647de1 100644 --- a/src/tools/musicBed.ts +++ b/src/tools/musicBed.ts @@ -15,6 +15,7 @@ const inputSchema = { fadeOut: z.number().nonnegative().optional().describe("Music fade-out seconds (default 2)."), duck: z.boolean().optional().describe("Duck the music under the original audio via a sidechain (default false)."), output: z.string().optional().describe("Optional output path. Defaults under SCREENCAST_HOME/edits."), + overwrite: z.boolean().optional().describe("Allow replacing an existing file at the output path (default false)."), }; export function register(server: McpServer): void { @@ -36,7 +37,7 @@ export function register(server: McpServer): void { throw new ScreencastError(`Could not read duration of ${args.video}.`); } const ext = extname(args.video) || ".mp4"; - const output = resolveOutput(args.output, subdir("edits"), `music-${stamp()}-${rand()}${ext}`); + const output = resolveOutput(args.output, subdir("edits"), `music-${stamp()}-${rand()}${ext}`, args.overwrite); const ffArgs = buildMusicBedArgs( args.video, args.music, diff --git a/src/tools/overlay.ts b/src/tools/overlay.ts index 1642045..adc5b12 100644 --- a/src/tools/overlay.ts +++ b/src/tools/overlay.ts @@ -17,6 +17,7 @@ const inputSchema = { start: z.number().nonnegative().optional().describe("Optional second to show the overlay from."), end: z.number().positive().optional().describe("Optional second to hide the overlay after."), output: z.string().optional().describe("Optional output path. Defaults under SCREENCAST_HOME/edits."), + overwrite: z.boolean().optional().describe("Allow replacing an existing file at the output path (default false)."), }; export function register(server: McpServer): void { @@ -39,6 +40,7 @@ export function register(server: McpServer): void { args.output, subdir("edits"), `overlay-${stamp()}-${rand()}${ext}`, + args.overwrite, ); const scale = args.width !== undefined || args.height !== undefined diff --git a/src/tools/redactRegion.ts b/src/tools/redactRegion.ts index eb70764..f5d83fe 100644 --- a/src/tools/redactRegion.ts +++ b/src/tools/redactRegion.ts @@ -34,6 +34,7 @@ const inputSchema = { .describe("Optional pixels to expand each region by, to cover anti-aliased edges (default 0)."), color: z.string().optional().describe("Fill color for box style (default black)."), output: z.string().optional().describe("Optional output path. Defaults under SCREENCAST_HOME/edits."), + overwrite: z.boolean().optional().describe("Allow replacing an existing file at the output path (default false)."), }; export function register(server: McpServer): void { @@ -57,6 +58,7 @@ export function register(server: McpServer): void { args.output, subdir("edits"), `redact-${stamp()}-${rand()}${ext}`, + args.overwrite, ); const ffArgs = buildRedactArgs( args.input, diff --git a/src/tools/reframe.ts b/src/tools/reframe.ts index 3f8d8a6..075afca 100644 --- a/src/tools/reframe.ts +++ b/src/tools/reframe.ts @@ -17,6 +17,7 @@ const inputSchema = { .optional() .describe("pad (default): scale to fit and letterbox, no content lost. crop: scale to fill and center-crop."), output: z.string().optional().describe("Optional output path. Defaults under SCREENCAST_HOME/edits."), + overwrite: z.boolean().optional().describe("Allow replacing an existing file at the output path (default false)."), }; export function register(server: McpServer): void { @@ -33,7 +34,7 @@ export function register(server: McpServer): void { throw new ScreencastError(`Input file not found: ${args.input}`); } const ext = extname(args.input) || ".mp4"; - const output = resolveOutput(args.output, subdir("edits"), `reframe-${stamp()}-${rand()}${ext}`); + const output = resolveOutput(args.output, subdir("edits"), `reframe-${stamp()}-${rand()}${ext}`, args.overwrite); const ffArgs = buildReframeArgs( args.input, output, diff --git a/src/tools/scale.ts b/src/tools/scale.ts index 681bf22..bf7393b 100644 --- a/src/tools/scale.ts +++ b/src/tools/scale.ts @@ -12,6 +12,7 @@ const inputSchema = { width: z.number().int().positive().optional().describe("Target width in px. Omit to derive from height (keeps aspect)."), height: z.number().int().positive().optional().describe("Target height in px. Omit to derive from width (keeps aspect)."), output: z.string().optional().describe("Optional output path. Defaults under SCREENCAST_HOME/edits."), + overwrite: z.boolean().optional().describe("Allow replacing an existing file at the output path (default false)."), }; export function register(server: McpServer): void { @@ -31,6 +32,7 @@ export function register(server: McpServer): void { args.output, subdir("edits"), `scale-${stamp()}-${rand()}${ext}`, + args.overwrite, ); const ffArgs = buildScaleArgs(args.input, output, { width: args.width, diff --git a/src/tools/screenshot.ts b/src/tools/screenshot.ts index a5cb842..0d27e9e 100644 --- a/src/tools/screenshot.ts +++ b/src/tools/screenshot.ts @@ -22,6 +22,7 @@ const inputSchema = { .describe( "Optional output .png path. Defaults to a file under SCREENCAST_HOME/screenshots.", ), + overwrite: z.boolean().optional().describe("Allow replacing an existing file at the output path (default false)."), }; export function register(server: McpServer): void { @@ -38,6 +39,7 @@ export function register(server: McpServer): void { args.output, subdir("screenshots"), `shot-${stamp()}-${rand()}.png`, + args.overwrite, ); await runFfmpeg(buildScreenshotArgs(target, output, monitors), 60_000); return okResponse({ diff --git a/src/tools/speed.ts b/src/tools/speed.ts index 47c4fd8..2eed775 100644 --- a/src/tools/speed.ts +++ b/src/tools/speed.ts @@ -11,6 +11,7 @@ const inputSchema = { input: z.string().min(1).describe("Path to the source video."), factor: z.number().positive().describe("Speed multiplier: >1 is faster, <1 is slower (e.g. 2 = double speed)."), output: z.string().optional().describe("Optional output path. Defaults under SCREENCAST_HOME/edits."), + overwrite: z.boolean().optional().describe("Allow replacing an existing file at the output path (default false)."), }; export function register(server: McpServer): void { @@ -31,6 +32,7 @@ export function register(server: McpServer): void { args.output, subdir("edits"), `speed-${stamp()}-${rand()}${ext}`, + args.overwrite, ); const ffArgs = buildSpeedArgs(args.input, output, args.factor, audioCodec !== null); await runFfmpeg(ffArgs, 10 * 60_000); diff --git a/src/tools/startRecording.ts b/src/tools/startRecording.ts index ee65906..c8e3959 100644 --- a/src/tools/startRecording.ts +++ b/src/tools/startRecording.ts @@ -60,6 +60,7 @@ const inputSchema = { .describe( "Optional output .mp4 path. Defaults to a file under SCREENCAST_HOME/recordings.", ), + overwrite: z.boolean().optional().describe("Allow replacing an existing file at the output path (default false)."), }; export function register(server: McpServer): void { @@ -81,6 +82,7 @@ export function register(server: McpServer): void { args.output, subdir("recordings"), `rec-${stamp()}-${rand()}.mp4`, + args.overwrite, ); // Resolve a loopback device up front so a missing one fails before the diff --git a/src/tools/titleCard.ts b/src/tools/titleCard.ts index 94bcaca..fb4abfe 100644 --- a/src/tools/titleCard.ts +++ b/src/tools/titleCard.ts @@ -18,6 +18,7 @@ const inputSchema = { fontSize: z.number().int().positive().optional().describe("Text size in px (default 96)."), bold: z.boolean().optional().describe("Use the bold weight (default true)."), output: z.string().optional().describe("Optional output path. Defaults under SCREENCAST_HOME/edits."), + overwrite: z.boolean().optional().describe("Allow replacing an existing file at the output path (default false)."), }; export function register(server: McpServer): void { @@ -31,7 +32,7 @@ export function register(server: McpServer): void { async (args) => { try { requireFfmpeg(); - const output = resolveOutput(args.output, subdir("edits"), `title-${stamp()}-${rand()}.mp4`); + const output = resolveOutput(args.output, subdir("edits"), `title-${stamp()}-${rand()}.mp4`, args.overwrite); const fontFile = bundledFontPath(args.bold === false ? "regular" : "bold"); // Write the text to a temp file so arbitrary content (quotes, colons, // percent signs) needs no inline filtergraph escaping. diff --git a/src/tools/trim.ts b/src/tools/trim.ts index 0a6b129..907ba64 100644 --- a/src/tools/trim.ts +++ b/src/tools/trim.ts @@ -13,6 +13,7 @@ const inputSchema = { end: z.number().positive().optional().describe("End time in seconds. Use end OR duration."), duration: z.number().positive().optional().describe("Clip length in seconds. Use end OR duration."), output: z.string().optional().describe("Optional output path. Defaults under SCREENCAST_HOME/edits."), + overwrite: z.boolean().optional().describe("Allow replacing an existing file at the output path (default false)."), }; export function register(server: McpServer): void { @@ -32,6 +33,7 @@ export function register(server: McpServer): void { args.output, subdir("edits"), `trim-${stamp()}-${rand()}${ext}`, + args.overwrite, ); const ffArgs = buildTrimArgs(args.input, output, { start: args.start, diff --git a/src/tools/xfadeTransition.ts b/src/tools/xfadeTransition.ts index aab4733..95f895c 100644 --- a/src/tools/xfadeTransition.ts +++ b/src/tools/xfadeTransition.ts @@ -19,6 +19,7 @@ const inputSchema = { .optional() .describe(`Transition length in seconds (default ${DEFAULT_TRANSITION_DUR}).`), output: z.string().optional().describe("Optional output path. Defaults under SCREENCAST_HOME/edits."), + overwrite: z.boolean().optional().describe("Allow replacing an existing file at the output path (default false)."), }; export function register(server: McpServer): void { @@ -43,6 +44,7 @@ export function register(server: McpServer): void { args.output, subdir("edits"), `xfade-${stamp()}-${rand()}.mp4`, + args.overwrite, ); const ffArgs = buildXfadeArgs( args.inputA, diff --git a/src/utils/paths.ts b/src/utils/paths.ts index 36387b4..c201bc9 100644 --- a/src/utils/paths.ts +++ b/src/utils/paths.ts @@ -9,7 +9,8 @@ */ import { homedir, tmpdir } from "node:os"; import { join, isAbsolute, resolve } from "node:path"; -import { mkdirSync } from "node:fs"; +import { mkdirSync, existsSync } from "node:fs"; +import { ScreencastError } from "./errors.js"; export function homeRoot(): string { const override = process.env.SCREENCAST_HOME; @@ -49,14 +50,27 @@ export function tempPath(suffix = ""): string { return join(tmpdir(), `screencast-${stamp()}-${rand()}${suffix}`); } -/** Resolve a caller-supplied output path, or build a default under a subdir. */ +/** Resolve a caller-supplied output path, or build a default under a subdir. + * + * A caller-supplied path that already exists is refused unless the caller + * passed overwrite: true - every ffmpeg builder runs with -y, so this is the + * one place that stands between a reused path and a silently clobbered file. + * Auto-generated default names are unique and skip the check. */ export function resolveOutput( provided: string | undefined, defaultDir: string, defaultName: string, + overwrite = false, ): string { if (provided && provided.trim().length > 0) { - return isAbsolute(provided) ? provided : resolve(provided); + const path = isAbsolute(provided) ? provided : resolve(provided); + if (!overwrite && existsSync(path)) { + throw new ScreencastError( + `Output already exists: ${path}. Pass overwrite: true to replace it, ` + + `or choose a different path.`, + ); + } + return path; } return join(defaultDir, defaultName); } From e9a64153f425eab121033f9cf97990e8560bb62b Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint <TMhospitalitystrategies@gmail.com> Date: Fri, 3 Jul 2026 12:31:03 -0400 Subject: [PATCH 4/4] chore: release 0.9.0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- CHANGELOG.md | 23 +++++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 760a828..14a22e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package-lock.json b/package-lock.json index c6ac379..6fd3db4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tmhs/screencast-mcp", - "version": "0.8.13", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tmhs/screencast-mcp", - "version": "0.8.13", + "version": "0.9.0", "license": "CC-BY-NC-ND-4.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", diff --git a/package.json b/package.json index ba3900c..1a829d6 100644 --- a/package.json +++ b/package.json @@ -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",