Forked from brdcastro/maestro
"use strict";
/**
* @file videoTools.ts
* Video rendering tool — runs HyperFrames against an HTML composition and
* produces an MP4. Thin wrapper around `npx hyperframes render`. Gated by
* the `enableVideoRendering` config flag (default OFF).
*
* The HTML schema the model must follow is documented in the
* `video-composition` design reference at
* ~/.maestro-toolbox/design-references/video-composition.md
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.createVideoTools = createVideoTools;
const sdk_1 = require("@lmstudio/sdk");
const zod_1 = require("zod");
const child_process_1 = require("child_process");
const util_1 = require("util");
const path_1 = require("path");
const promises_1 = require("fs/promises");
const shared_1 = require("./shared");
const errorCodes_1 = require("./errorCodes");
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
// Render can take from a few seconds (10s clip) to several minutes (long
// videos with many transitions). 10 minutes is a generous ceiling — anything
// past that probably means something is stuck.
const RENDER_TIMEOUT_MS = 10 * 60 * 1000;
function createVideoTools(ctx, config) {
const renderTool = (0, sdk_1.tool)({
name: "render_html_video",
description: "Render a HyperFrames-compatible HTML composition to an MP4 video. " +
"The HTML MUST be named 'index.html' and live inside its own project directory — " +
"HyperFrames auto-detects the entry point from the cwd, there is no --input flag. " +
"Load the 'video-composition' design reference first if you haven't already. " +
"Requires Node 22+ and FFmpeg installed on the user's machine. " +
"First call may be slow (npx fetches the hyperframes package); subsequent calls " +
"are fast. Render time scales with composition duration — a 10s clip is ~30-60s, " +
"a 60s clip with shader transitions can take several minutes.",
parameters: {
html_path: zod_1.z.string().describe("Path to the entry HTML file (absolute or relative to cwd). MUST end in 'index.html'. HyperFrames runs from its parent directory and auto-detects the entry point — there is no way to pass a different filename."),
output_name: zod_1.z.string().optional().describe("Output MP4 filename (default: 'output.mp4'). Saved next to the index.html."),
},
implementation: (0, shared_1.createSafeToolImplementation)(async ({ html_path, output_name }) => {
// Resolve and validate the HTML path stays inside the workspace.
// validatePath handles both absolute and relative paths via resolve().
let absoluteHtmlPath;
try {
absoluteHtmlPath = (0, shared_1.validatePath)(ctx.cwd, html_path, ctx.workspaceRoot);
}
catch (e) {
return (0, errorCodes_1.toolError)(errorCodes_1.PATH_DENIED, e.message);
}
// Confirm the file exists and is a regular file
try {
const s = await (0, promises_1.stat)(absoluteHtmlPath);
if (!s.isFile()) {
return (0, errorCodes_1.toolError)(errorCodes_1.FILE_NOT_FOUND, `Path exists but is not a regular file: ${absoluteHtmlPath}`);
}
}
catch {
return (0, errorCodes_1.toolError)(errorCodes_1.FILE_NOT_FOUND, `HTML file not found: ${absoluteHtmlPath}`);
}
// HyperFrames CLI auto-detects index.html from the cwd; there is no
// --input flag. If the file isn't named index.html, hyperframes
// treats the basename as a project subdirectory and errors with
// "Not a directory: ...". Fail fast with a clear hint instead.
const htmlBasename = (0, path_1.basename)(absoluteHtmlPath);
if (htmlBasename.toLowerCase() !== "index.html") {
return (0, errorCodes_1.toolError)(errorCodes_1.EXEC_FAILED, `HyperFrames requires the entry file to be named 'index.html', got '${htmlBasename}'.`, `Save the composition as 'index.html' in its own project directory. Re-run save_file with the corrected path.`);
}
const projectDir = (0, path_1.dirname)(absoluteHtmlPath);
const outName = output_name && output_name.trim() ? output_name.trim() : "output.mp4";
const outPath = (0, path_1.resolve)(projectDir, outName);
try {
// The LM Studio plugin process inherits a minimal PATH that does NOT
// include /opt/homebrew/bin or nvm/volta locations — calling npx
// directly fails with ENOENT. Spawning the user's login shell with
// -lc sources their rc files (.zshrc / .bash_profile) and gets the
// full PATH that they'd see in Terminal.
//
// This is the same root cause as the screencapture ENOENT we hit on
// Apr 25 — fixed there with an absolute /usr/sbin path. Here we go
// through the login shell instead since npx location varies per
// user (homebrew vs nvm vs volta vs system Node).
const userShell = process.env.SHELL || "/bin/zsh";
const sh = (s) => `'${s.replace(/'/g, "'\\''")}'`;
// HyperFrames CLI: only --output is a real flag. The entry HTML is
// auto-detected as index.html from cwd. No --input.
// (We validated htmlBasename === "index.html" above.)
const renderCmd = `cd ${sh(projectDir)} && ` +
`npx --yes hyperframes render --output ${sh(outName)}`;
const { stdout, stderr } = await execFileAsync(userShell, ["-lc", renderCmd], {
timeout: RENDER_TIMEOUT_MS,
maxBuffer: 10 * 1024 * 1024,
});
// Verify the MP4 actually landed
try {
const outStat = await (0, promises_1.stat)(outPath);
if (!outStat.isFile() || outStat.size === 0) {
return (0, errorCodes_1.toolError)(errorCodes_1.EXEC_FAILED, `Render finished but output file is missing or empty: ${outPath}`, `Check stderr for hints: ${stderr.slice(0, 500)}`);
}
return {
success: true,
output_path: outPath,
bytes: outStat.size,
project_dir: projectDir,
note: stderr.trim() ? `Rendered with warnings. Last stderr lines: ${stderr.split("\n").slice(-5).join(" | ")}` : "Rendered cleanly.",
};
}
catch {
return (0, errorCodes_1.toolError)(errorCodes_1.EXEC_FAILED, `Render command finished but expected output file does not exist: ${outPath}`, `stdout: ${stdout.slice(0, 300)} | stderr: ${stderr.slice(0, 300)}`);
}
}
catch (e) {
// Distinguish common failure modes for actionable hints
const msg = String(e?.message || e);
const lower = msg.toLowerCase();
let hint;
if (lower.includes("command not found") || lower.includes("npx: not found")) {
hint = `npx is not on the PATH of your login shell (${process.env.SHELL || "default"}). Install Node 22+ from https://nodejs.org or via 'brew install node'. If you use nvm/volta/asdf, ensure your shell rc file (.zshrc, .bash_profile) sources it BEFORE the plugin starts — restart LM Studio after editing.`;
}
else if (lower.includes("ffmpeg")) {
hint = "FFmpeg missing or wrong version. Install via: brew install ffmpeg (macOS) or apt install ffmpeg (Linux).";
}
else if (lower.includes("timed out") || lower.includes("etimedout")) {
hint = "Render exceeded 10-minute timeout. Likely a runaway timeline or repeat:-1 somewhere. Check the 'video-composition' reference for the rules.";
}
else if (lower.includes("composition") || lower.includes("__timelines")) {
hint = "HTML schema invalid. Reload the 'video-composition' design reference and verify: data-composition-id present on root, GSAP timeline { paused: true } registered to window.__timelines, no async timeline construction.";
}
else if (lower.includes("enoent") && lower.includes(process.env.SHELL?.split("/").pop() || "shell")) {
hint = `Login shell ${process.env.SHELL} not found. Set SHELL env var or install zsh/bash. macOS default is /bin/zsh.`;
}
return (0, errorCodes_1.toolError)(errorCodes_1.EXEC_FAILED, `Render failed: ${msg.slice(0, 500)}`, hint);
}
}, config.enableVideoRendering, "render_html_video"),
});
return [renderTool];
}
//# sourceMappingURL=videoTools.js.map