@fugood/buttress-server 2.25.2 → 2.25.3
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 +75 -3
- package/config/sample.toml +21 -0
- package/lib/functions/templates.d.ts +1 -1
- package/lib/index.mjs +47 -44
- package/lib/routes/tts-shared.d.ts +2 -2
- package/lib/services/ggml-tts.d.ts +42 -0
- package/lib/services/index.d.ts +3 -0
- package/lib/types.d.ts +1 -1
- package/lib/utils/fileResponse.d.ts +14 -0
- package/package.json +3 -3
- package/public/status.html +101 -1
package/README.md
CHANGED
|
@@ -229,7 +229,7 @@ Every generator entry has a `type`, an optional `[generators.backend]` table, an
|
|
|
229
229
|
|
|
230
230
|
```toml
|
|
231
231
|
[[generators]]
|
|
232
|
-
type = "ggml-llm" # or "ggml-stt" / "mlx-llm"
|
|
232
|
+
type = "ggml-llm" # or "ggml-stt" / "ggml-tts" / "mlx-llm"
|
|
233
233
|
|
|
234
234
|
[generators.backend]
|
|
235
235
|
# (see per-type sections below)
|
|
@@ -249,7 +249,7 @@ Shared by **all** generator types:
|
|
|
249
249
|
| `revision` | string | Default `"main"` |
|
|
250
250
|
| `download` | boolean | Pre-download at server startup (default `false`) |
|
|
251
251
|
|
|
252
|
-
Additional keys honored by **ggml-llm** and **ggml-
|
|
252
|
+
Additional keys honored by **ggml-llm**, **ggml-stt** and **ggml-tts** (mlx-llm gets quantization from the repo itself and does not use these):
|
|
253
253
|
|
|
254
254
|
| Key | Type | Notes |
|
|
255
255
|
| ------------------------- | --------- | -------------------------------------------------------------------------------- |
|
|
@@ -395,6 +395,78 @@ download = true
|
|
|
395
395
|
|
|
396
396
|
---
|
|
397
397
|
|
|
398
|
+
### `ggml-tts` (codec.cpp via `@fugood/llama.node`)
|
|
399
|
+
|
|
400
|
+
Loads a TTS backbone GGUF plus its audio codec / vocoder GGUF and synthesizes
|
|
401
|
+
speech. Both artifacts stay resident, and both are resolved from `repo_id`
|
|
402
|
+
unless `vocoder_repo_id` points elsewhere — repos that ship the backbone and
|
|
403
|
+
codec together are split by filename (`codec` / `vocoder` / `wavtokenizer` /
|
|
404
|
+
`dac` / `mimi`).
|
|
405
|
+
|
|
406
|
+
Every family the native layer detects runs here — OuteTTS, Soprano, NeuTTS,
|
|
407
|
+
CSM, Qwen3-TTS, MOSS-TTSD, MOSS-TTS-Realtime, Chatterbox and BlueMagpie. The
|
|
408
|
+
family and the synthesis flow (token vs continuous-latent) are decided
|
|
409
|
+
natively, so no per-family configuration is needed. `getCapabilities` reports
|
|
410
|
+
the list under `families`, and a client asking for a family an older server
|
|
411
|
+
does not advertise stays local rather than getting wrong audio back.
|
|
412
|
+
|
|
413
|
+
> No phonemizer is wired into this backend, so NeuTTS receives raw text rather
|
|
414
|
+
> than phonemes — the same limitation the local generator has.
|
|
415
|
+
|
|
416
|
+
**`[generators.backend]`**
|
|
417
|
+
|
|
418
|
+
| Key | Type | Default | Notes |
|
|
419
|
+
| --------------------- | -------- | ----------------------------- | ----------------------------- |
|
|
420
|
+
| `variant` | string | auto | `cuda` / `vulkan` / `default` |
|
|
421
|
+
| `variant_preference` | string[] | `["cuda","vulkan","default"]` | Probe order |
|
|
422
|
+
| `gpu_memory_fraction` | number | `0.85` | |
|
|
423
|
+
| `cpu_memory_fraction` | number | `0.5` | |
|
|
424
|
+
|
|
425
|
+
**`[generators.model]`** — common keys plus:
|
|
426
|
+
|
|
427
|
+
| Key | Type | Default | Notes |
|
|
428
|
+
| ------------------------- | -------- | ------------------------------------ | ----------------------------------------------------------- |
|
|
429
|
+
| `preferred_quantizations` | string[] | `["q4_k_m", "q8_0", <no-quant>]` | Default fallback chain |
|
|
430
|
+
| `vocoder_repo_id` | string | falls back to `repo_id` | Repo holding the codec GGUF |
|
|
431
|
+
| `vocoder_filename` | string | auto | Pin a specific codec artifact |
|
|
432
|
+
| `vocoder_url` | string | — | Direct codec URL (skips manifest lookup) |
|
|
433
|
+
| `vocoder_revision` | string | falls back to `revision` | |
|
|
434
|
+
| `vocoder_local_path` | string | — | Requires `allow_local_file` |
|
|
435
|
+
| `use_gpu` | boolean | `true` | Force-disable GPU even when available |
|
|
436
|
+
| `n_gpu_layers` | number | `99` | Forced to `0` when the GPU is unused |
|
|
437
|
+
| `sample_rate` | number | `24000` | Fallback only — the real rate is read from the loaded codec |
|
|
438
|
+
|
|
439
|
+
**Runtime extras** — under `[runtime]` for ggml-tts:
|
|
440
|
+
|
|
441
|
+
| Key | Type | Default | Notes |
|
|
442
|
+
| ---------------------- | ------ | ------- | ---------------------------------------------------------- |
|
|
443
|
+
| `n_ctx` | number | `8192` | Backbone context size |
|
|
444
|
+
| `n_batch` / `n_ubatch` | number | `8192` / `512` | |
|
|
445
|
+
| `vocoder_batch_size` | number | `4096` | Passed to `initVocoder` |
|
|
446
|
+
| `max_threads` | number | auto | Caps the llama.cpp thread count |
|
|
447
|
+
| `output_cache.*` | table | enabled, `2GB`, 5000 entries | Synthesized WAVs, keyed by text + model + options |
|
|
448
|
+
|
|
449
|
+
**Example**
|
|
450
|
+
|
|
451
|
+
```toml
|
|
452
|
+
[[generators]]
|
|
453
|
+
type = "ggml-tts"
|
|
454
|
+
[generators.backend]
|
|
455
|
+
variant_preference = ["cuda", "vulkan", "default"]
|
|
456
|
+
[generators.model]
|
|
457
|
+
repo_id = "OuteAI/OuteTTS-1.0-0.6B-GGUF"
|
|
458
|
+
quantization = "q4_k_m"
|
|
459
|
+
vocoder_repo_id = "BricksDisplay/codec.cpp-gguf"
|
|
460
|
+
vocoder_filename = "ibm-research--DAC.speech.gguf"
|
|
461
|
+
use_gpu = true
|
|
462
|
+
download = true
|
|
463
|
+
[generators.runtime]
|
|
464
|
+
n_ctx = 8192
|
|
465
|
+
vocoder_batch_size = 4096
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
---
|
|
469
|
+
|
|
398
470
|
### `mlx-llm` (Apple Silicon, Python `mlx-lm` / `mlx-vlm` bridge)
|
|
399
471
|
|
|
400
472
|
Loads an MLX-format model on Apple Silicon. On first use, the backend creates a virtualenv at `{cache_dir}/mlx-env` and installs `mlx_lm_package`, `mlx_vlm_package`, plus `torch` and `torchvision` (required by some VLM processors). If an existing venv already has `mlx_vlm` and `torch` importable, the install step is skipped. There is no `[generators.backend]` section.
|
|
@@ -620,7 +692,7 @@ export default async function ({ path }: { path: string }, context: ButtressFunc
|
|
|
620
692
|
| `buttress.tokenize({ model?, text, params? })` | Tokenize with a configured GGML or MLX LLM → `{ tokens: number[], … }`. |
|
|
621
693
|
| `buttress.detokenize({ model?, tokens })` | Convert token ids back into text with the same model. |
|
|
622
694
|
| `buttress.transcribe({ model?, filePath \| audioData, options? })` | Transcribe audio with this server's STT generator. |
|
|
623
|
-
| `buttress.synthesize({ model?, text, options? })` | Synthesize speech with this server's TTS generator (`onnx-tts`); the WAV lands in `tempDir` → `{ path, sampling_rate, channels }`. |
|
|
695
|
+
| `buttress.synthesize({ model?, text, options? })` | Synthesize speech with this server's TTS generator (`onnx-tts` or `ggml-tts`); the WAV lands in `tempDir` → `{ path, sampling_rate, channels }`. |
|
|
624
696
|
| `emit(event, data)` | Progress event; delivered to SSE callers, ignored otherwise. |
|
|
625
697
|
| `signal` | `AbortSignal`, aborted on timeout or caller disconnect. |
|
|
626
698
|
| `tempDir` | Per-call scratch directory, created on first access. |
|
package/config/sample.toml
CHANGED
|
@@ -250,3 +250,24 @@ filename = "ggml-large-v3-turbo-q8_0.bin"
|
|
|
250
250
|
download = false
|
|
251
251
|
use_gpu = true
|
|
252
252
|
use_flash_attn = true
|
|
253
|
+
|
|
254
|
+
# Text-to-Speech (TTS) generators
|
|
255
|
+
#
|
|
256
|
+
# A GGML TTS generator needs two GGUFs: the backbone LM and its codec /
|
|
257
|
+
# vocoder. Both are resolved from `repo_id` unless `vocoder_repo_id` is set.
|
|
258
|
+
# The family (OuteTTS / CSM / Qwen3-TTS / Chatterbox / BlueMagpie / ...) is
|
|
259
|
+
# detected natively from the backbone, so no extra configuration is needed.
|
|
260
|
+
[[generators]]
|
|
261
|
+
type = "ggml-tts"
|
|
262
|
+
[generators.backend]
|
|
263
|
+
variant_preference = ["cuda", "vulkan", "default"]
|
|
264
|
+
[generators.model]
|
|
265
|
+
repo_id = "OuteAI/OuteTTS-1.0-0.6B-GGUF"
|
|
266
|
+
quantization = "q4_k_m"
|
|
267
|
+
vocoder_repo_id = "BricksDisplay/codec.cpp-gguf"
|
|
268
|
+
vocoder_filename = "ibm-research--DAC.speech.gguf"
|
|
269
|
+
download = true
|
|
270
|
+
use_gpu = true
|
|
271
|
+
[generators.runtime]
|
|
272
|
+
n_ctx = 8192
|
|
273
|
+
vocoder_batch_size = 4096
|
|
@@ -9,6 +9,6 @@
|
|
|
9
9
|
export declare const TYPES_FILE_NAME = "buttress-functions.d.ts";
|
|
10
10
|
export declare const TSCONFIG_FILE_NAME = "tsconfig.json";
|
|
11
11
|
export declare const EXAMPLE_FILE_NAME = "_example.ts";
|
|
12
|
-
export declare const TYPES_TEMPLATE = "// Generated by @fugood/buttress-server \u2014 do not edit.\n// Rewritten on every server start to match the running version.\n//\n// Local functions are EXPERIMENTAL: this contract may change between\n// releases \u2014 after a server upgrade, re-read this file for the current shape.\n\n/** Result of `context.spawn(...)`. */\ntype ButtressSpawnResult = {\n /** Exit code, or null when the process was killed by a signal. */\n code: number | null\n signal: NodeJS.Signals | null\n stdout: string | Buffer\n stderr: string | Buffer\n /** True when output hit `maxBuffer` and capture stopped early. */\n truncated: boolean\n}\n\ntype ButtressSpawnOptions = {\n cwd?: string\n /** Merged over the server's own environment. */\n env?: Record<string, string | undefined>\n /** Written to stdin, which is then closed. */\n input?: string | Uint8Array\n /** 'utf8' (default) yields strings; 'buffer' yields Buffers. */\n encoding?: 'utf8' | 'buffer'\n /** Per-stream capture cap in bytes (default 8MB). */\n maxBuffer?: number\n onStdout?: (chunk: Buffer) => void\n onStderr?: (chunk: Buffer) => void\n}\n\ntype ButtressCompletionResult = {\n content: string\n reasoning_content?: string\n tool_calls?: any[]\n interrupted?: boolean\n usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number }\n}\n\ntype ButtressEmbeddingResult = {\n /** Plain numbers, ready for JSON or conversion into a sqlite-vec float32 BLOB. */\n embedding: number[]\n}\n\ntype ButtressFunctionContext = {\n /**\n * Run a child process. Resolves once it exits \u2014 a non-zero `code` is a\n * normal resolution, so check it yourself. Rejects only when the process\n * cannot be started. Every process a call spawns is killed when the call\n * ends or times out.\n */\n spawn: (\n command: string,\n args?: string[],\n options?: ButtressSpawnOptions,\n ) => Promise<ButtressSpawnResult>\n\n buttress: {\n /**\n * Run a chat completion on this server's LLM generator. `messages` are\n * rendered with the model's own chat template, and thinking is off unless\n * you pass `enable_thinking: true` (which fills `reasoning_content`).\n */\n completion: (options: {\n /** Configured `[[generators]]` model; defaults to the first one. */\n model?: string\n messages?: { role: string; content: any }[]\n prompt?: string\n /** Cap on generated tokens. Unbounded when omitted. */\n max_tokens?: number\n temperature?: number\n /** Emit the model's reasoning into `reasoning_content` (default false). */\n enable_thinking?: boolean\n /** Called for every streamed token event. */\n onToken?: (event: { token?: string; content?: string; [key: string]: any }) => void\n /** Any other backend sampling option (`top_p`, `stop`, `tools`, \u2026). */\n [param: string]: any\n }) => Promise<ButtressCompletionResult>\n\n /**\n * Embed text with a GGML generator whose `[generators.model]` table sets\n * `embedding = true`. The vector is returned as plain numbers.\n */\n embedding: (options: {\n model?: string\n text: string\n /** llama.cpp normalization mode; 2 (L2) is the native default. */\n embd_normalize?: number\n }) => Promise<ButtressEmbeddingResult>\n\n /** Tokenize text with a configured GGML or MLX generator. */\n tokenize: (options: {\n model?: string\n text: string\n params?: Record<string, any>\n }) => Promise<Record<string, any> & { tokens: number[] }>\n\n /** Turn model token ids back into text. */\n detokenize: (options: { model?: string; tokens: number[] }) => Promise<string>\n\n /** Transcribe audio with this server's STT generator. */\n transcribe: (options: {\n /** Must match a configured STT model (`repo_id` or `repo_id:filename`). */\n model?: string\n filePath?: string\n audioData?: Uint8Array | Buffer\n options?: Record<string, any>\n }) => Promise<any>\n\n /**\n * Synthesize speech with this server's TTS generator (onnx-tts). The WAV
|
|
12
|
+
export declare const TYPES_TEMPLATE = "// Generated by @fugood/buttress-server \u2014 do not edit.\n// Rewritten on every server start to match the running version.\n//\n// Local functions are EXPERIMENTAL: this contract may change between\n// releases \u2014 after a server upgrade, re-read this file for the current shape.\n\n/** Result of `context.spawn(...)`. */\ntype ButtressSpawnResult = {\n /** Exit code, or null when the process was killed by a signal. */\n code: number | null\n signal: NodeJS.Signals | null\n stdout: string | Buffer\n stderr: string | Buffer\n /** True when output hit `maxBuffer` and capture stopped early. */\n truncated: boolean\n}\n\ntype ButtressSpawnOptions = {\n cwd?: string\n /** Merged over the server's own environment. */\n env?: Record<string, string | undefined>\n /** Written to stdin, which is then closed. */\n input?: string | Uint8Array\n /** 'utf8' (default) yields strings; 'buffer' yields Buffers. */\n encoding?: 'utf8' | 'buffer'\n /** Per-stream capture cap in bytes (default 8MB). */\n maxBuffer?: number\n onStdout?: (chunk: Buffer) => void\n onStderr?: (chunk: Buffer) => void\n}\n\ntype ButtressCompletionResult = {\n content: string\n reasoning_content?: string\n tool_calls?: any[]\n interrupted?: boolean\n usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number }\n}\n\ntype ButtressEmbeddingResult = {\n /** Plain numbers, ready for JSON or conversion into a sqlite-vec float32 BLOB. */\n embedding: number[]\n}\n\ntype ButtressFunctionContext = {\n /**\n * Run a child process. Resolves once it exits \u2014 a non-zero `code` is a\n * normal resolution, so check it yourself. Rejects only when the process\n * cannot be started. Every process a call spawns is killed when the call\n * ends or times out.\n */\n spawn: (\n command: string,\n args?: string[],\n options?: ButtressSpawnOptions,\n ) => Promise<ButtressSpawnResult>\n\n buttress: {\n /**\n * Run a chat completion on this server's LLM generator. `messages` are\n * rendered with the model's own chat template, and thinking is off unless\n * you pass `enable_thinking: true` (which fills `reasoning_content`).\n */\n completion: (options: {\n /** Configured `[[generators]]` model; defaults to the first one. */\n model?: string\n messages?: { role: string; content: any }[]\n /**\n * Raw prompt sent without the chat template \u2014 the fallback for models\n * whose template the server cannot apply. Use instead of `messages`.\n */\n prompt?: string\n /** Cap on generated tokens. Unbounded when omitted. */\n max_tokens?: number\n temperature?: number\n /** Emit the model's reasoning into `reasoning_content` (default false). */\n enable_thinking?: boolean\n /** Called for every streamed token event. */\n onToken?: (event: { token?: string; content?: string; [key: string]: any }) => void\n /** Any other backend sampling option (`top_p`, `stop`, `tools`, \u2026). */\n [param: string]: any\n }) => Promise<ButtressCompletionResult>\n\n /**\n * Embed text with a GGML generator whose `[generators.model]` table sets\n * `embedding = true`. The vector is returned as plain numbers.\n */\n embedding: (options: {\n model?: string\n text: string\n /** llama.cpp normalization mode; 2 (L2) is the native default. */\n embd_normalize?: number\n }) => Promise<ButtressEmbeddingResult>\n\n /** Tokenize text with a configured GGML or MLX generator. */\n tokenize: (options: {\n model?: string\n text: string\n params?: Record<string, any>\n }) => Promise<Record<string, any> & { tokens: number[] }>\n\n /** Turn model token ids back into text. */\n detokenize: (options: { model?: string; tokens: number[] }) => Promise<string>\n\n /** Transcribe audio with this server's STT generator. */\n transcribe: (options: {\n /** Must match a configured STT model (`repo_id` or `repo_id:filename`). */\n model?: string\n filePath?: string\n audioData?: Uint8Array | Buffer\n options?: Record<string, any>\n }) => Promise<any>\n\n /**\n * Synthesize speech with this server's TTS generator (onnx-tts or\n * ggml-tts). The WAV is written into `tempDir`; return\n * `fileUrl(path)` to let callers download it.\n */\n synthesize: (options: {\n /** Must match a configured TTS model when given. */\n model?: string\n text: string\n /** Backend options; `options.speaker` picks a registered voice. */\n options?: Record<string, any>\n }) => Promise<{ path: string; sampling_rate: number; channels: number }>\n }\n\n fetch: typeof fetch\n /** Server-side log, prefixed with the function name. */\n log: (...args: unknown[]) => void\n /** Emit a progress event. Delivered only to SSE callers; a no-op otherwise. */\n emit: (event: string, data?: unknown) => void\n /** Aborted when the call times out or the caller disconnects. */\n signal: AbortSignal\n env: Record<string, string | undefined>\n /**\n * The `[functions.config]` table from the server config. Read once at\n * server start \u2014 unlike function files, config edits need a restart.\n */\n config: Record<string, any>\n /** Per-call scratch directory, created on first access. */\n tempDir: string\n /**\n * Download URL path (`/functions/files/...`) for a file inside `tempDir`;\n * relative input resolves against it. Callers fetch the URL with the same\n * auth as any function call. Scratch dirs are swept after ~24h.\n */\n fileUrl: (target: string) => string\n /** Absolute path of this functions directory. */\n dir: string\n /** Helper libraries: _, lodash, moment, math, mathjs, voca, chroma, json5, qs, bytes, ms, nanoid, md5. */\n libs: Record<string, any>\n}\n\ntype ButtressFunctionMeta = {\n /** Shown to MCP clients in `tools/list`. */\n description?: string\n /** JSON Schema for the input object, passed to MCP verbatim. */\n parameters?: Record<string, any>\n /** Deadline for this function: ms, or a duration string like \"10m\". */\n timeout?: number | string\n}\n\n// --- Custom auth ------------------------------------------------------------\n// Drop an `_auth.ts` into this directory to gate the /functions endpoints\n// with your own logic. It exports the same shape as a function file:\n//\n// export const meta: ButtressAuthMeta = { mode: 'both' }\n// export default async function (\n// request: ButtressAuthRequest,\n// context: ButtressAuthContext,\n// ): Promise<ButtressAuthResult> { ... }\n//\n// While an `_auth` file exists but fails to load, every call is rejected.\n\ntype ButtressAuthMeta = {\n /**\n * 'both' (default): runs after the built-in workspace auth passes, as an\n * extra gate. 'override': replaces workspace auth \u2014 this function is the\n * only authority (a presented workspace token is still verified into\n * `request.workspaceAuth` so you can choose to honor it).\n */\n mode?: 'override' | 'both'\n}\n\ntype ButtressAuthRequest = {\n method: string\n path: string\n /** Function name for `GET`/`POST /functions/<name>`; undefined for list and MCP. */\n name?: string\n headers: Record<string, string | undefined>\n query: Record<string, string | undefined>\n /** Raw bearer token (Authorization header or `?token=`), if any. */\n token: string | null\n workspaceAuth: {\n /** Whether this server is bound to a workspace. */\n bound: boolean\n /** Whether the caller presented a valid workspace access token. */\n authenticated: boolean\n identity: {\n workspaceId: string\n subjectType: 'ws' | 'dev'\n subjectId: string\n jti?: string\n exp: number\n } | null\n }\n}\n\ntype ButtressAuthContext = {\n /** Server-side log, prefixed with \"_auth\". */\n log: (...args: unknown[]) => void\n fetch: typeof fetch\n env: Record<string, string | undefined>\n /** The `[functions.config]` table, same as `context.config` in functions. */\n config: Record<string, any>\n /** Absolute path of this functions directory. */\n dir: string\n /** Same helper libraries functions get. */\n libs: Record<string, any>\n}\n\n/** Only `true` (or `{ ok: true }`) allows the request; anything else denies. */\ntype ButtressAuthResult =\n | boolean\n | {\n ok: boolean\n /** Response status for a denial, 400-499 (default 403). */\n status?: number\n /** Message returned to the caller on denial. */\n error?: string\n }\n\n// The only non-builtin packages the local-function loader accepts. These\n// declarations keep editor support working when this directory is outside the\n// server package's own node_modules resolution tree.\ndeclare module 'sqlite3' {\n type SqliteCallback = (error: Error | null) => void\n\n class Database {\n constructor(filename: string, callback?: SqliteCallback)\n run(sql: string, ...params: any[]): this\n all<T = Record<string, any>>(\n sql: string,\n ...params: [...any[], (error: Error | null, rows: T[]) => void]\n ): this\n exec(sql: string, callback?: SqliteCallback): this\n loadExtension(filename: string, callback?: SqliteCallback): this\n close(callback?: SqliteCallback): void\n }\n\n const sqlite3: { Database: typeof Database }\n export { Database }\n export default sqlite3\n}\n\ndeclare module 'sqlite-vec' {\n export function getLoadablePath(): string\n export function load(database: { loadExtension(path: string): unknown }): void\n}\n";
|
|
13
13
|
export declare const TSCONFIG_TEMPLATE = "{\n // Editor support for Buttress local functions.\n // Install @types/node here for typings on \"node:*\" imports.\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"ESNext\",\n \"moduleResolution\": \"Bundler\",\n \"lib\": [\"ES2023\"],\n \"strict\": true,\n \"noEmit\": true,\n \"allowJs\": true,\n \"skipLibCheck\": true\n },\n \"include\": [\"**/*.ts\", \"**/*.js\"]\n}\n";
|
|
14
14
|
export declare const EXAMPLE_TEMPLATE = "// Example Buttress local function.\n//\n// Rename (or copy) this file to expose it: files starting with \"_\" are\n// ignored. The tool name is the file name \u2014 \"video-duration.ts\" becomes the\n// tool \"video-duration\", callable over MCP, at POST /functions/video-duration\n// with a JSON body, and at GET /functions/video-duration?path=... with the\n// input in the query string.\n\nexport const meta: ButtressFunctionMeta = {\n description: 'Report the duration of a video file using ffprobe',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Absolute path to a video file' },\n },\n required: ['path'],\n },\n timeout: '2m',\n}\n\nexport default async function (\n { path }: { path: string },\n context: ButtressFunctionContext,\n): Promise<{ seconds: number }> {\n const { code, stdout, stderr } = await context.spawn('ffprobe', [\n '-v',\n 'error',\n '-show_entries',\n 'format=duration',\n '-of',\n 'default=noprint_wrappers=1:nokey=1',\n path,\n ])\n\n if (code !== 0) throw new Error(`ffprobe failed (${code}): ${stderr}`)\n\n return { seconds: Number(String(stdout).trim()) }\n}\n";
|