@minicor/mcp-server 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -270,7 +270,7 @@ After all steps pass individually:
270
270
 
271
271
  Then close the loop at the job level:
272
272
 
273
- - Point the job's workflow step at the finished `workflowId` (`update_job variant=draft`). If the workflow was edited in Dev Mode, `dev_mode_publish` first - jobs execute published workflows, not Dev Mode branches.
273
+ - Point the job's workflow step at the finished `workflowId` (`update_job variant=draft`). If the workflow was edited in Dev Mode, `dev_mode_publish` first - jobs execute published workflows, not drafts.
274
274
  - `run_tests variant=draft` - the test cases from Phase 3 are the pass/fail verdict for the build
275
275
  - On a red case, `resolve_job_state` / `inspect_job_execution` locate the failing step; replay just that slice with `run_job` (`fromStepId` + `seedExecutionId`) or drop back into the workflow, fix, re-run
276
276
  - When the suite is green, `publish_job` puts it live at `/m/:slug/:path`
@@ -345,7 +345,7 @@ You work **backwards from the job's test cases**. The job's test suite is the ma
345
345
  seedExecutionId=<failed execution> (seeds ctx so mid-graph starts
346
346
  resolve earlier steps' values).
347
347
  5b. Workflow-level (microscope) - the failing step's workflowId -> dev_mode_load,
348
- fix/run the branch's flow steps with dev_mode_run,
348
+ fix/run the draft's flow steps (dev_mode_run needs vmId),
349
349
  dev_mode_publish - jobs execute PUBLISHED workflows.
350
350
  6. PUBLISH - when run_tests variant=draft is green, publish_job promotes the draft;
351
351
  runtime traffic at /m/:slug serves it immediately.
@@ -686,6 +686,12 @@ Jobs/middleware base resolution (precedence: **proxy > direct > default**):
686
686
  | `MIDDLEWARE_SERVICE_URL_US` | Direct middleware-service base for US (overrides the generic one) | unset |
687
687
  | `MIDDLEWARE_SERVICE_URL_CA` | Direct middleware-service base for CA (overrides the generic one) | unset |
688
688
  | `EXPERIMENTAL_USE_CODE_MODE` | Expose the experimental single `codemode` tool surface | unset (standard tools) |
689
+ | `SEEER_SERVICE_PROXY_BASE` | Frontend origin fronting the video-tool proxy (auth = user bearer, like the agents/stitchy proxies) | unset |
690
+ | `SEEER_SERVICE_PROXY_PREFIX` | video-tool proxy path prefix | `/api/video-tool` |
691
+ | `SEEER_SERVICE_URL` | Direct video-tool service URL (enables `analyze_recording` / `annotate_recording` / `get_recording_event`; frame tools route through it) | unset (local ffmpeg) |
692
+ | `SEEER_SERVICE_TOKEN` | App token for direct mode (sent as X-API-Key) | unset |
693
+ | `SEEER_SERVICE_ID_TOKEN` | Static Google identity token for IAM-gated Cloud Run (local testing) | unset |
694
+ | `SEEER_SERVICE_USE_GCP_IDENTITY` | Set to 1 to mint identity tokens from the GCP metadata server | unset |
689
695
 
690
696
  When none are set, Jobs calls the region's public middleware-service Cloud Run directly. To route through the frontend (once it deploys an `/api/middleware/[...path]` proxy), set `MIDDLEWARE_SERVICE_PROXY_BASE` (or the per-region variants).
691
697
 
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { computeSampleTimestamps, buildScaleFilter, runCommand, } from "../recording-frames.js";
2
+ import { computeSampleTimestamps, computeRangeTimestamps, buildScaleFilter, buildCropFilter, cacheKeyForUrl, runCommand, } from "../recording-frames.js";
3
3
  describe("computeSampleTimestamps", () => {
4
4
  it("returns evenly-spaced, centered timestamps within the duration", () => {
5
5
  expect(computeSampleTimestamps(10, 4)).toEqual([1.25, 3.75, 6.25, 8.75]);
@@ -22,6 +22,47 @@ describe("computeSampleTimestamps", () => {
22
22
  expect(computeSampleTimestamps(Number.NaN, 4)).toEqual([]);
23
23
  });
24
24
  });
25
+ describe("computeRangeTimestamps", () => {
26
+ it("samples the window at the requested fps, inclusive of both ends", () => {
27
+ expect(computeRangeTimestamps(10, 14, 1, 16)).toEqual([10, 11, 12, 13, 14]);
28
+ });
29
+ it("falls back to even spacing when fps would exceed maxFrames", () => {
30
+ const ts = computeRangeTimestamps(0, 30, 2, 4);
31
+ expect(ts).toHaveLength(4);
32
+ expect(ts[0]).toBe(0);
33
+ expect(ts[3]).toBe(30);
34
+ });
35
+ it("returns a single frame for a zero-ish span with n=1", () => {
36
+ expect(computeRangeTimestamps(5, 5.4, 1, 16)).toEqual([5]);
37
+ });
38
+ it("returns empty for invalid input", () => {
39
+ expect(computeRangeTimestamps(10, 5, 1, 16)).toEqual([]);
40
+ expect(computeRangeTimestamps(0, 10, 0, 16)).toEqual([]);
41
+ expect(computeRangeTimestamps(0, 10, 1, 0)).toEqual([]);
42
+ expect(computeRangeTimestamps(Number.NaN, 10, 1, 16)).toEqual([]);
43
+ });
44
+ });
45
+ describe("buildCropFilter", () => {
46
+ it("builds a crop for the region with padding", () => {
47
+ expect(buildCropFilter({ x: 100, y: 50, width: 200, height: 100, pad: 10 })).toBe("crop=220:120:90:40");
48
+ });
49
+ it("clamps to video bounds", () => {
50
+ expect(buildCropFilter({ x: -20, y: -20, width: 3000, height: 2000 }, 2560, 1440)).toBe("crop=2560:1440:0:0");
51
+ });
52
+ it("returns null when the region is entirely outside the video", () => {
53
+ expect(buildCropFilter({ x: 5000, y: 5000, width: 100, height: 100 }, 2560, 1440)).toBeNull();
54
+ });
55
+ });
56
+ describe("cacheKeyForUrl", () => {
57
+ it("ignores the SAS query so re-minted URLs share a cache entry", () => {
58
+ const a = cacheKeyForUrl("https://acct.blob.core.windows.net/replay/rpa_1_2.mp4?sig=abc&se=2026");
59
+ const b = cacheKeyForUrl("https://acct.blob.core.windows.net/replay/rpa_1_2.mp4?sig=zzz&se=2027");
60
+ expect(a).toBe(b);
61
+ });
62
+ it("differs for different blobs", () => {
63
+ expect(cacheKeyForUrl("https://acct.blob.core.windows.net/replay/rpa_1_2.mp4")).not.toBe(cacheKeyForUrl("https://acct.blob.core.windows.net/replay/rpa_1_3.mp4"));
64
+ });
65
+ });
25
66
  describe("runCommand", () => {
26
67
  it("resolves on a zero exit code", async () => {
27
68
  const result = await runCommand(process.execPath, ["-e", "process.stdout.write('hi')"], 10_000);
@@ -1 +1 @@
1
- {"version":3,"file":"recording-frames.test.js","sourceRoot":"","sources":["../../src/__tests__/recording-frames.test.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"recording-frames.test.js","sourceRoot":"","sources":["../../src/__tests__/recording-frames.test.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -153,7 +153,7 @@ async function unsetStoredApiBase(region) {
153
153
  }
154
154
  const codeModeEnabled = shouldUseCodeMode();
155
155
  const instructions = buildInstructions(true);
156
- const server = new McpServer({ name: SERVER_NAME, version: "4.0.0" }, {
156
+ const server = new McpServer({ name: SERVER_NAME, version: "4.2.0" }, {
157
157
  instructions: codeModeEnabled
158
158
  ? buildCodeModeInstructions(instructions, { sessionLifecycle: true })
159
159
  : instructions,
package/dist/lib.js CHANGED
@@ -60,7 +60,7 @@ export async function createMinicorServer(transport, opts = {}) {
60
60
  const localTools = opts.localTools ?? true;
61
61
  const codeModeEnabled = opts.codeMode ?? shouldUseCodeMode();
62
62
  const instructions = buildInstructions(localTools);
63
- const server = new McpServer({ name: SERVER_NAME, version: "4.0.0" }, {
63
+ const server = new McpServer({ name: SERVER_NAME, version: "4.2.0" }, {
64
64
  instructions: codeModeEnabled
65
65
  ? buildCodeModeInstructions(instructions, {
66
66
  sessionLifecycle: localTools,
@@ -1,12 +1,22 @@
1
1
  /**
2
- * Extracts sampled still frames from a recording (MP4) so that a vision-capable
3
- * model can visually verify or debug an execution.
2
+ * Extracts still frames from a recording (MP4) so that a vision-capable model
3
+ * can visually verify or debug an execution.
4
4
  *
5
5
  * The recording endpoints return a signed URL to an MP4. A model cannot "watch"
6
- * a video URL, so this module downloads the video and uses ffmpeg to sample a
7
- * handful of frames, returned as base64 JPEGs. ffmpeg (and ideally ffprobe) must
8
- * be available on the MCP host; when they are missing we throw a clear error so
9
- * the caller can fall back to handing the raw URL to a human.
6
+ * a video URL, so this module downloads the video and uses ffmpeg to extract
7
+ * frames, returned as base64 JPEGs. Supports:
8
+ * - evenly-spaced sampling across the whole video (default)
9
+ * - exact timestamps ("show me t=41.5s")
10
+ * - dense sampling of a time range ("what happened between t=30s and t=45s")
11
+ * - region cropping at the recording's native resolution ("zoom in here")
12
+ *
13
+ * Downloads are cached under ~/.minicor/cache/recordings keyed on the blob
14
+ * identity (URL minus the SAS query), so successive calls against the same
15
+ * recording skip the download entirely.
16
+ *
17
+ * ffmpeg (and ideally ffprobe) must be available on the MCP host; when they are
18
+ * missing we throw a clear error so the caller can fall back to handing the raw
19
+ * URL to a human.
10
20
  */
11
21
  export interface ExtractedFrame {
12
22
  /** Human-readable label, e.g. "t=12.3s" or "frame 2". */
@@ -16,15 +26,49 @@ export interface ExtractedFrame {
16
26
  /** Base64-encoded JPEG (no data: prefix). */
17
27
  base64: string;
18
28
  }
29
+ export interface FrameRegion {
30
+ /** Left edge in native video pixels. */
31
+ x: number;
32
+ /** Top edge in native video pixels. */
33
+ y: number;
34
+ width: number;
35
+ height: number;
36
+ /** Extra context padding around the region, in pixels. Default 0. */
37
+ pad?: number;
38
+ }
39
+ export interface FrameRange {
40
+ /** Window start in seconds. */
41
+ start: number;
42
+ /** Window end in seconds. */
43
+ end: number;
44
+ /** Sampling rate within the window. Default 1 fps. */
45
+ fps?: number;
46
+ }
19
47
  export interface ExtractFramesOptions {
20
- /** Number of evenly-spaced frames to sample. Default 4. */
48
+ /**
49
+ * Number of evenly-spaced frames to sample. Default 4. Ignored when
50
+ * `timestamps` or `range` is provided.
51
+ */
21
52
  count?: number;
53
+ /** Exact timestamps (seconds) to extract. Takes precedence over `range`/`count`. */
54
+ timestamps?: number[];
55
+ /** Dense sampling of a time window. Takes precedence over `count`. */
56
+ range?: FrameRange;
57
+ /** Safety cap on total frames for timestamps/range modes. Default 16. */
58
+ maxFrames?: number;
59
+ /**
60
+ * Crop to this region at the recording's NATIVE resolution before any
61
+ * scaling — the way to keep small on-screen text readable.
62
+ */
63
+ region?: FrameRegion;
22
64
  /**
23
65
  * Optional max output width in px. When omitted (the default) frames keep the
24
66
  * recording's ORIGINAL resolution — important for reading fine on-screen detail.
25
67
  * Only set this if you explicitly want to trade resolution for smaller frames.
26
68
  */
27
69
  maxWidth?: number;
70
+ /** Use the on-disk download cache. Default true. */
71
+ cache?: boolean;
28
72
  /** Max bytes to download before aborting. Default 200 MB. */
29
73
  maxBytes?: number;
30
74
  /** Overall download timeout in ms. Default 60s. */
@@ -39,11 +83,22 @@ export interface ExtractFramesOptions {
39
83
  * avoid black/blank frames at the very start and end.
40
84
  */
41
85
  export declare function computeSampleTimestamps(durationSeconds: number, count: number): number[];
86
+ /**
87
+ * Computes timestamps for dense sampling of a [start, end] window at `fps`,
88
+ * capped at `maxFrames` (falling back to even spacing across the window when
89
+ * the requested rate would exceed the cap).
90
+ */
91
+ export declare function computeRangeTimestamps(start: number, end: number, fps: number, maxFrames: number): number[];
42
92
  /**
43
93
  * Builds an ffmpeg scale filter that caps width while preserving aspect ratio.
44
94
  * Returns null when no cap is requested, so frames keep their original resolution.
45
95
  */
46
96
  export declare function buildScaleFilter(maxWidth?: number): string | null;
97
+ /**
98
+ * Builds an ffmpeg crop filter for a region (with optional padding), clamped to
99
+ * the video bounds when they are known. Returns null for degenerate regions.
100
+ */
101
+ export declare function buildCropFilter(region: FrameRegion, videoWidth?: number | null, videoHeight?: number | null): string | null;
47
102
  export interface CommandResult {
48
103
  code: number | null;
49
104
  stdout: string;
@@ -51,8 +106,26 @@ export interface CommandResult {
51
106
  }
52
107
  export declare function runCommand(command: string, args: string[], timeoutMs: number): Promise<CommandResult>;
53
108
  /**
54
- * Downloads the recording at `url` and returns sampled JPEG frames.
55
- * Throws with a clear message when ffmpeg is unavailable.
109
+ * Stable cache key for a recording URL: the blob identity without the SAS
110
+ * query, so re-minted signed URLs for the same blob hit the same cache entry.
111
+ */
112
+ export declare function cacheKeyForUrl(url: string): string;
113
+ /**
114
+ * Returns a local path to the recording, downloading it if not already cached.
115
+ * The returned file must NOT be deleted by the caller (it is shared).
116
+ */
117
+ export declare function getCachedRecording(url: string, opts: {
118
+ maxBytes: number;
119
+ downloadTimeoutMs: number;
120
+ }): Promise<string>;
121
+ export interface VideoInfo {
122
+ durationSeconds: number | null;
123
+ width: number | null;
124
+ height: number | null;
125
+ }
126
+ /**
127
+ * Downloads the recording at `url` (via the shared cache) and returns extracted
128
+ * JPEG frames. Throws with a clear message when ffmpeg is unavailable.
56
129
  */
57
130
  export declare function extractFramesFromUrl(url: string, options?: ExtractFramesOptions): Promise<ExtractedFrame[]>;
58
131
  //# sourceMappingURL=recording-frames.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"recording-frames.d.ts","sourceRoot":"","sources":["../src/recording-frames.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAOH,MAAM,WAAW,cAAc;IAC7B,yDAAyD;IACzD,KAAK,EAAE,MAAM,CAAC;IACd,4DAA4D;IAC5D,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,6CAA6C;IAC7C,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,oBAAoB;IACnC,2DAA2D;IAC3D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,mDAAmD;IACnD,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,6DAA6D;IAC7D,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAWD;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,eAAe,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CASxF;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAKjE;AAID,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,UAAU,CACxB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,EACd,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,aAAa,CAAC,CA4CxB;AAqHD;;;GAGG;AACH,wBAAsB,oBAAoB,CACxC,GAAG,EAAE,MAAM,EACX,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,cAAc,EAAE,CAAC,CAuF3B"}
1
+ {"version":3,"file":"recording-frames.d.ts","sourceRoot":"","sources":["../src/recording-frames.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAoBH,MAAM,WAAW,cAAc;IAC7B,yDAAyD;IACzD,KAAK,EAAE,MAAM,CAAC;IACd,4DAA4D;IAC5D,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,6CAA6C;IAC7C,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,WAAW;IAC1B,wCAAwC;IACxC,CAAC,EAAE,MAAM,CAAC;IACV,uCAAuC;IACvC,CAAC,EAAE,MAAM,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,UAAU;IACzB,+BAA+B;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,6BAA6B;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,sDAAsD;IACtD,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,oBAAoB;IACnC;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,oFAAoF;IACpF,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,sEAAsE;IACtE,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,yEAAyE;IACzE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,oDAAoD;IACpD,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,mDAAmD;IACnD,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,6DAA6D;IAC7D,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAeD;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,eAAe,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CASxF;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,MAAM,GAChB,MAAM,EAAE,CAiBV;AAID;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAKjE;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,WAAW,EACnB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,EAC1B,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,GAC1B,MAAM,GAAG,IAAI,CAgBf;AA2BD,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,UAAU,CACxB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,EACd,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,aAAa,CAAC,CA4CxB;AAQD;;;GAGG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CASlD;AAwCD;;;GAGG;AACH,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,MAAM,EACX,IAAI,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,iBAAiB,EAAE,MAAM,CAAA;CAAE,GACpD,OAAO,CAAC,MAAM,CAAC,CA2BjB;AA2CD,MAAM,WAAW,SAAS;IACxB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAwID;;;GAGG;AACH,wBAAsB,oBAAoB,CACxC,GAAG,EAAE,MAAM,EACX,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,cAAc,EAAE,CAAC,CA8F3B"}
@@ -1,25 +1,40 @@
1
1
  /**
2
- * Extracts sampled still frames from a recording (MP4) so that a vision-capable
3
- * model can visually verify or debug an execution.
2
+ * Extracts still frames from a recording (MP4) so that a vision-capable model
3
+ * can visually verify or debug an execution.
4
4
  *
5
5
  * The recording endpoints return a signed URL to an MP4. A model cannot "watch"
6
- * a video URL, so this module downloads the video and uses ffmpeg to sample a
7
- * handful of frames, returned as base64 JPEGs. ffmpeg (and ideally ffprobe) must
8
- * be available on the MCP host; when they are missing we throw a clear error so
9
- * the caller can fall back to handing the raw URL to a human.
6
+ * a video URL, so this module downloads the video and uses ffmpeg to extract
7
+ * frames, returned as base64 JPEGs. Supports:
8
+ * - evenly-spaced sampling across the whole video (default)
9
+ * - exact timestamps ("show me t=41.5s")
10
+ * - dense sampling of a time range ("what happened between t=30s and t=45s")
11
+ * - region cropping at the recording's native resolution ("zoom in here")
12
+ *
13
+ * Downloads are cached under ~/.minicor/cache/recordings keyed on the blob
14
+ * identity (URL minus the SAS query), so successive calls against the same
15
+ * recording skip the download entirely.
16
+ *
17
+ * ffmpeg (and ideally ffprobe) must be available on the MCP host; when they are
18
+ * missing we throw a clear error so the caller can fall back to handing the raw
19
+ * URL to a human.
10
20
  */
11
21
  import { spawn } from "node:child_process";
12
- import { mkdtemp, open, readFile, readdir, rm } from "node:fs/promises";
22
+ import { createHash } from "node:crypto";
23
+ import { mkdtemp, mkdir, open, readFile, readdir, rename, rm, stat, utimes, } from "node:fs/promises";
13
24
  import { tmpdir } from "node:os";
14
25
  import path from "node:path";
26
+ import { MINICOR_DIR } from "./paths.js";
15
27
  const DEFAULTS = {
16
28
  count: 4,
29
+ maxFrames: 16,
30
+ cache: true,
17
31
  maxBytes: 200 * 1024 * 1024,
18
32
  downloadTimeoutMs: 60_000,
19
33
  commandTimeoutMs: 30_000,
20
34
  ffmpegPath: "ffmpeg",
21
35
  ffprobePath: "ffprobe",
22
36
  };
37
+ // ── Sampling math ────────────────────────────────────────────
23
38
  /**
24
39
  * Computes evenly-spaced sample timestamps centered within the duration, so we
25
40
  * avoid black/blank frames at the very start and end.
@@ -34,6 +49,29 @@ export function computeSampleTimestamps(durationSeconds, count) {
34
49
  return Number((durationSeconds * fraction).toFixed(3));
35
50
  });
36
51
  }
52
+ /**
53
+ * Computes timestamps for dense sampling of a [start, end] window at `fps`,
54
+ * capped at `maxFrames` (falling back to even spacing across the window when
55
+ * the requested rate would exceed the cap).
56
+ */
57
+ export function computeRangeTimestamps(start, end, fps, maxFrames) {
58
+ if (!Number.isFinite(start) ||
59
+ !Number.isFinite(end) ||
60
+ end <= start ||
61
+ !Number.isFinite(fps) ||
62
+ fps <= 0 ||
63
+ maxFrames <= 0) {
64
+ return [];
65
+ }
66
+ const span = end - start;
67
+ const requested = Math.floor(span * fps) + 1;
68
+ const n = Math.min(requested, maxFrames);
69
+ if (n === 1)
70
+ return [Number(start.toFixed(3))];
71
+ const step = span / (n - 1);
72
+ return Array.from({ length: n }, (_, i) => Number((start + i * step).toFixed(3)));
73
+ }
74
+ // ── ffmpeg filters ───────────────────────────────────────────
37
75
  /**
38
76
  * Builds an ffmpeg scale filter that caps width while preserving aspect ratio.
39
77
  * Returns null when no cap is requested, so frames keep their original resolution.
@@ -44,6 +82,43 @@ export function buildScaleFilter(maxWidth) {
44
82
  }
45
83
  return `scale='min(${maxWidth},iw)':-2`;
46
84
  }
85
+ /**
86
+ * Builds an ffmpeg crop filter for a region (with optional padding), clamped to
87
+ * the video bounds when they are known. Returns null for degenerate regions.
88
+ */
89
+ export function buildCropFilter(region, videoWidth, videoHeight) {
90
+ const pad = region.pad ?? 0;
91
+ let x0 = Math.floor(region.x - pad);
92
+ let y0 = Math.floor(region.y - pad);
93
+ let x1 = Math.ceil(region.x + region.width + pad);
94
+ let y1 = Math.ceil(region.y + region.height + pad);
95
+ x0 = Math.max(x0, 0);
96
+ y0 = Math.max(y0, 0);
97
+ if (videoWidth != null && Number.isFinite(videoWidth))
98
+ x1 = Math.min(x1, videoWidth);
99
+ if (videoHeight != null && Number.isFinite(videoHeight))
100
+ y1 = Math.min(y1, videoHeight);
101
+ const w = x1 - x0;
102
+ const h = y1 - y0;
103
+ if (w <= 0 || h <= 0)
104
+ return null;
105
+ return `crop=${w}:${h}:${x0}:${y0}`;
106
+ }
107
+ function buildVideoFilter(region, maxWidth, videoWidth, videoHeight) {
108
+ const parts = [];
109
+ if (region) {
110
+ const crop = buildCropFilter(region, videoWidth, videoHeight);
111
+ if (!crop) {
112
+ throw new Error("the requested region falls entirely outside the video bounds");
113
+ }
114
+ parts.push(crop);
115
+ }
116
+ const scale = buildScaleFilter(maxWidth);
117
+ if (scale)
118
+ parts.push(scale);
119
+ return parts.length > 0 ? parts.join(",") : null;
120
+ }
121
+ // ── Command execution ────────────────────────────────────────
47
122
  class CommandNotFoundError extends Error {
48
123
  }
49
124
  export function runCommand(command, args, timeoutMs) {
@@ -87,6 +162,98 @@ export function runCommand(command, args, timeoutMs) {
87
162
  });
88
163
  });
89
164
  }
165
+ // ── Download cache ───────────────────────────────────────────
166
+ const CACHE_DIR = path.join(MINICOR_DIR, "cache", "recordings");
167
+ const CACHE_TTL_MS = 30 * 60 * 1000; // 30 min — SAS URLs are short-lived anyway
168
+ const CACHE_MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2 GB
169
+ /**
170
+ * Stable cache key for a recording URL: the blob identity without the SAS
171
+ * query, so re-minted signed URLs for the same blob hit the same cache entry.
172
+ */
173
+ export function cacheKeyForUrl(url) {
174
+ let identity = url;
175
+ try {
176
+ const parsed = new URL(url);
177
+ identity = `${parsed.origin}${parsed.pathname}`;
178
+ }
179
+ catch {
180
+ // Not a parseable URL — hash the raw string.
181
+ }
182
+ return createHash("sha1").update(identity).digest("hex");
183
+ }
184
+ async function evictCache() {
185
+ let entries = [];
186
+ try {
187
+ const files = await readdir(CACHE_DIR);
188
+ for (const f of files) {
189
+ if (!f.endsWith(".mp4"))
190
+ continue;
191
+ const full = path.join(CACHE_DIR, f);
192
+ try {
193
+ const s = await stat(full);
194
+ entries.push({ file: full, mtimeMs: s.mtimeMs, size: s.size });
195
+ }
196
+ catch {
197
+ /* raced with another eviction */
198
+ }
199
+ }
200
+ }
201
+ catch {
202
+ return;
203
+ }
204
+ const now = Date.now();
205
+ const fresh = [];
206
+ for (const e of entries) {
207
+ if (now - e.mtimeMs > CACHE_TTL_MS) {
208
+ await rm(e.file, { force: true }).catch(() => { });
209
+ }
210
+ else {
211
+ fresh.push(e);
212
+ }
213
+ }
214
+ let total = fresh.reduce((sum, e) => sum + e.size, 0);
215
+ if (total <= CACHE_MAX_BYTES)
216
+ return;
217
+ fresh.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
218
+ for (const e of fresh) {
219
+ if (total <= CACHE_MAX_BYTES)
220
+ break;
221
+ await rm(e.file, { force: true }).catch(() => { });
222
+ total -= e.size;
223
+ }
224
+ }
225
+ /**
226
+ * Returns a local path to the recording, downloading it if not already cached.
227
+ * The returned file must NOT be deleted by the caller (it is shared).
228
+ */
229
+ export async function getCachedRecording(url, opts) {
230
+ // Private permissions: recordings can contain sensitive screen content.
231
+ await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
232
+ const dest = path.join(CACHE_DIR, `${cacheKeyForUrl(url)}.mp4`);
233
+ try {
234
+ const s = await stat(dest);
235
+ if (s.size > 0 && Date.now() - s.mtimeMs <= CACHE_TTL_MS) {
236
+ // Touch for LRU so hot recordings survive eviction.
237
+ const now = new Date();
238
+ await utimes(dest, now, now).catch(() => { });
239
+ return dest;
240
+ }
241
+ }
242
+ catch {
243
+ /* not cached yet */
244
+ }
245
+ const partial = `${dest}.${process.pid}.${Date.now()}.part`;
246
+ try {
247
+ await downloadToFile(url, partial, opts.maxBytes, opts.downloadTimeoutMs);
248
+ await rename(partial, dest);
249
+ }
250
+ finally {
251
+ // A failed/aborted download must not leave partial bytes accumulating.
252
+ await rm(partial, { force: true }).catch(() => { });
253
+ }
254
+ await evictCache();
255
+ return dest;
256
+ }
90
257
  async function downloadToFile(url, dest, maxBytes, timeoutMs) {
91
258
  const controller = new AbortController();
92
259
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -102,7 +269,7 @@ async function downloadToFile(url, dest, maxBytes, timeoutMs) {
102
269
  if (!res.body) {
103
270
  throw new Error("recording download returned an empty body");
104
271
  }
105
- const handle = await open(dest, "w");
272
+ const handle = await open(dest, "w", 0o600);
106
273
  try {
107
274
  let total = 0;
108
275
  for await (const chunk of res.body) {
@@ -121,30 +288,46 @@ async function downloadToFile(url, dest, maxBytes, timeoutMs) {
121
288
  clearTimeout(timer);
122
289
  }
123
290
  }
124
- async function probeDurationSeconds(file, ffprobePath, timeoutMs) {
291
+ async function probeVideo(file, ffprobePath, timeoutMs) {
125
292
  try {
126
- const { stdout } = await runCommand(ffprobePath, ["-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", file], timeoutMs);
127
- const duration = parseFloat(stdout.trim());
128
- return Number.isFinite(duration) && duration > 0 ? duration : null;
293
+ const { stdout } = await runCommand(ffprobePath, [
294
+ "-v",
295
+ "error",
296
+ "-select_streams",
297
+ "v:0",
298
+ "-show_entries",
299
+ "stream=width,height:format=duration",
300
+ "-of",
301
+ "json",
302
+ file,
303
+ ], timeoutMs);
304
+ const parsed = JSON.parse(stdout);
305
+ const duration = parseFloat(parsed?.format?.duration);
306
+ const width = Number(parsed?.streams?.[0]?.width);
307
+ const height = Number(parsed?.streams?.[0]?.height);
308
+ return {
309
+ durationSeconds: Number.isFinite(duration) && duration > 0 ? duration : null,
310
+ width: Number.isFinite(width) && width > 0 ? width : null,
311
+ height: Number.isFinite(height) && height > 0 ? height : null,
312
+ };
129
313
  }
130
- catch (e) {
131
- if (e instanceof CommandNotFoundError) {
132
- return null; // ffprobe missing; caller falls back to thumbnail sampling
133
- }
134
- return null;
314
+ catch {
315
+ // ffprobe missing or unparseable; caller falls back to thumbnail sampling.
316
+ return { durationSeconds: null, width: null, height: null };
135
317
  }
136
318
  }
137
- async function extractFrameAt(file, timestampSeconds, outPath, scaleFilter, ffmpegPath, timeoutMs) {
319
+ // ── Frame extraction ─────────────────────────────────────────
320
+ async function extractFrameAt(file, timestampSeconds, outPath, videoFilter, ffmpegPath, timeoutMs) {
138
321
  const args = ["-ss", String(timestampSeconds), "-i", file, "-frames:v", "1"];
139
- if (scaleFilter) {
140
- args.push("-vf", scaleFilter);
322
+ if (videoFilter) {
323
+ args.push("-vf", videoFilter);
141
324
  }
142
325
  // -q:v 2 = highest-quality JPEG, so the original resolution is preserved with minimal artifacts.
143
326
  args.push("-q:v", "2", "-y", outPath);
144
327
  await runCommand(ffmpegPath, args, timeoutMs);
145
328
  }
146
- async function extractThumbnails(file, count, outPattern, scaleFilter, ffmpegPath, timeoutMs) {
147
- const vf = scaleFilter ? `thumbnail,${scaleFilter}` : "thumbnail";
329
+ async function extractThumbnails(file, count, outPattern, videoFilter, ffmpegPath, timeoutMs) {
330
+ const vf = videoFilter ? `thumbnail,${videoFilter}` : "thumbnail";
148
331
  await runCommand(ffmpegPath, [
149
332
  "-i",
150
333
  file,
@@ -169,27 +352,66 @@ async function readJpegBase64(file) {
169
352
  return null;
170
353
  }
171
354
  }
355
+ function resolveTimestamps(opts, durationSeconds) {
356
+ if (opts.timestamps && opts.timestamps.length > 0) {
357
+ // Sorted copy: callers are promised chronological frames.
358
+ const valid = [...opts.timestamps]
359
+ .filter((t) => Number.isFinite(t) && t >= 0)
360
+ .sort((a, b) => a - b)
361
+ .slice(0, opts.maxFrames);
362
+ return valid.map((t) => {
363
+ // Clamp seeks slightly inside the end so they land on a frame.
364
+ if (durationSeconds != null && t >= durationSeconds) {
365
+ return Number(Math.max(durationSeconds - 0.05, 0).toFixed(3));
366
+ }
367
+ return Number(t.toFixed(3));
368
+ });
369
+ }
370
+ if (opts.range) {
371
+ const end = durationSeconds != null
372
+ ? Math.min(opts.range.end, durationSeconds - 0.01)
373
+ : opts.range.end;
374
+ return computeRangeTimestamps(opts.range.start, end, opts.range.fps ?? 1, opts.maxFrames);
375
+ }
376
+ if (durationSeconds != null) {
377
+ return computeSampleTimestamps(durationSeconds, opts.count);
378
+ }
379
+ return null; // no duration and no explicit targets: thumbnail fallback
380
+ }
172
381
  /**
173
- * Downloads the recording at `url` and returns sampled JPEG frames.
174
- * Throws with a clear message when ffmpeg is unavailable.
382
+ * Downloads the recording at `url` (via the shared cache) and returns extracted
383
+ * JPEG frames. Throws with a clear message when ffmpeg is unavailable.
175
384
  */
176
385
  export async function extractFramesFromUrl(url, options = {}) {
177
386
  const opts = { ...DEFAULTS, ...options };
178
- const scaleFilter = buildScaleFilter(opts.maxWidth);
179
387
  const workDir = await mkdtemp(path.join(tmpdir(), "laminar-frames-"));
180
- const videoPath = path.join(workDir, "recording.mp4");
388
+ let videoPath;
181
389
  try {
182
- await downloadToFile(url, videoPath, opts.maxBytes, opts.downloadTimeoutMs);
183
- const duration = await probeDurationSeconds(videoPath, opts.ffprobePath, opts.commandTimeoutMs);
390
+ if (opts.cache) {
391
+ try {
392
+ videoPath = await getCachedRecording(url, opts);
393
+ }
394
+ catch {
395
+ // Cache dir unusable (permissions, disk) — fall back to a temp download.
396
+ videoPath = path.join(workDir, "recording.mp4");
397
+ await downloadToFile(url, videoPath, opts.maxBytes, opts.downloadTimeoutMs);
398
+ }
399
+ }
400
+ else {
401
+ videoPath = path.join(workDir, "recording.mp4");
402
+ await downloadToFile(url, videoPath, opts.maxBytes, opts.downloadTimeoutMs);
403
+ }
404
+ const info = await probeVideo(videoPath, opts.ffprobePath, opts.commandTimeoutMs);
405
+ const videoFilter = buildVideoFilter(opts.region, opts.maxWidth, info.width, info.height);
406
+ const timestamps = resolveTimestamps(opts, info.durationSeconds);
184
407
  const frames = [];
185
408
  try {
186
- if (duration != null) {
187
- const timestamps = computeSampleTimestamps(duration, opts.count);
409
+ if (timestamps != null) {
188
410
  let lastError = null;
189
411
  for (let i = 0; i < timestamps.length; i++) {
190
412
  const outPath = path.join(workDir, `frame-${i}.jpg`);
191
413
  try {
192
- await extractFrameAt(videoPath, timestamps[i], outPath, scaleFilter, opts.ffmpegPath, opts.commandTimeoutMs);
414
+ await extractFrameAt(videoPath, timestamps[i], outPath, videoFilter, opts.ffmpegPath, opts.commandTimeoutMs);
193
415
  }
194
416
  catch (e) {
195
417
  // ffmpeg missing is fatal; a single bad frame (e.g. a seek that lands
@@ -216,7 +438,7 @@ export async function extractFramesFromUrl(url, options = {}) {
216
438
  else {
217
439
  // No duration (ffprobe missing) — fall back to thumbnail sampling.
218
440
  const outPattern = path.join(workDir, "thumb-%03d.jpg");
219
- await extractThumbnails(videoPath, opts.count, outPattern, scaleFilter, opts.ffmpegPath, opts.commandTimeoutMs);
441
+ await extractThumbnails(videoPath, opts.count, outPattern, videoFilter, opts.ffmpegPath, opts.commandTimeoutMs);
220
442
  const files = (await readdir(workDir))
221
443
  .filter((f) => f.startsWith("thumb-") && f.endsWith(".jpg"))
222
444
  .sort();