Forked from brdcastro/maestro
"use strict";
/**
* @file Video metadata tool — extracts evenly-spaced frames to disk and returns paths.
*
* IMPORTANT: Never returns base64 frames in the result. Embedding base64 in tool output
* floods the conversation context because every subsequent tool call re-sends all prior
* context. Frames are saved to a stable directory next to the video so the user can
* attach them to the chat if they need visual analysis by a vision model.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.createVideoAnalysisTool = createVideoAnalysisTool;
const sdk_1 = require("@lmstudio/sdk");
const zod_1 = require("zod");
const promises_1 = require("fs/promises");
const path_1 = require("path");
const child_process_1 = require("child_process");
const util_1 = require("util");
const ffmpegPath_1 = require("./ffmpegPath");
const shared_1 = require("./shared");
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
const SUPPORTED_VIDEO_EXTENSIONS = new Set([
".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v",
]);
function createVideoAnalysisTool(configFrameCount = 4, _configMaxDim = 384) {
return (0, sdk_1.tool)({
name: "analyze_video",
description: "Extract evenly-spaced frames from a local video file and save them to disk. " +
"Returns duration + frame file paths (no image data in the result). " +
"If visual analysis is needed, attach the saved frames to the chat directly. " +
"Supports MP4, MOV, AVI, MKV, WebM.",
parameters: {
file_path: zod_1.z.string().describe("Absolute path to the video file."),
frame_count: zod_1.z
.number()
.int()
.min(1)
.max(10)
.optional()
.describe("Number of evenly-spaced frames to extract. Default: 4."),
},
implementation: async ({ file_path, frame_count }, { status, warn }) => {
const numFrames = frame_count ?? configFrameCount;
const resolvedPath = (0, path_1.isAbsolute)(file_path) ? file_path : (0, path_1.resolve)(file_path);
if (!(0, shared_1.isSafePath)(resolvedPath)) {
return { error: `Access denied: '${resolvedPath}' is in a protected system directory.` };
}
const ext = (0, path_1.extname)(resolvedPath).toLowerCase();
if (!SUPPORTED_VIDEO_EXTENSIONS.has(ext)) {
return {
error: `Unsupported video format '${ext}'. Supported: ${[...SUPPORTED_VIDEO_EXTENSIONS].join(", ")}`,
};
}
try {
const fileStat = await (0, promises_1.stat)(resolvedPath);
if (!fileStat.isFile()) {
return { error: `Not a file: ${resolvedPath}` };
}
}
catch {
return { error: `File not found: ${resolvedPath}` };
}
// Save frames alongside the video in a dedicated folder
const videoBase = (0, path_1.basename)(resolvedPath, ext);
const framesDir = (0, path_1.join)((0, path_1.dirname)(resolvedPath), `${videoBase}_frames`);
await (0, promises_1.mkdir)(framesDir, { recursive: true });
try {
const ffmpeg = await (0, ffmpegPath_1.getFfmpegPath)();
const ffprobe = await (0, ffmpegPath_1.getFfprobePath)();
status("Probing video metadata...");
let duration;
try {
const { stdout } = await execFileAsync(ffprobe, [
"-v", "quiet",
"-print_format", "json",
"-show_format",
resolvedPath,
]);
const probeData = JSON.parse(stdout);
duration = parseFloat(probeData?.format?.duration ?? "0");
}
catch (err) {
return {
error: `Could not probe video: ${err?.message || String(err)}`,
};
}
if (duration <= 0) {
return { error: "Could not determine video duration." };
}
const timestamps = Array.from({ length: numFrames }, (_, i) => (duration / (numFrames + 1)) * (i + 1));
status(`Extracting ${numFrames} frames from ${duration.toFixed(1)}s video...`);
const frames = await Promise.all(timestamps.map(async (t, i) => {
const outputPath = (0, path_1.join)(framesDir, `frame_${String(i).padStart(3, "0")}.jpg`);
try {
await execFileAsync(ffmpeg, [
"-ss", String(t),
"-i", resolvedPath,
"-frames:v", "1",
"-q:v", "3",
"-y",
outputPath,
]);
return {
index: i,
timestamp_s: Math.round(t * 10) / 10,
file_path: outputPath,
};
}
catch (err) {
warn(`Failed to extract frame at ${t.toFixed(1)}s: ${err?.message || String(err)}`);
return null;
}
}));
const validFrames = frames.filter((f) => f !== null);
if (validFrames.length === 0) {
return { error: "Failed to extract any frames from the video." };
}
status(`Done — ${validFrames.length} frames saved to ${framesDir}`);
return {
file_path: resolvedPath,
duration_s: Math.round(duration * 10) / 10,
frames_dir: framesDir,
frame_count: validFrames.length,
frames: validFrames,
};
}
catch (err) {
return {
error: `Failed to process video: ${err?.message || String(err)}`,
file_path: resolvedPath,
};
}
},
});
}
//# sourceMappingURL=videoAnalysis.js.map