UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

291 lines (290 loc) 10 kB
import { a as normalizeLowercaseStringOrEmpty } from "./string-coerce-mnp54Vah.js"; import { r as writeExternalFileWithinRoot } from "./fs-safe-aqmM_n6V.js"; import { n as resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir-DOKojISm.js"; import { n as tempWorkspaceSync, r as withTempWorkspace } from "./private-temp-workspace-MCwLg_M9.js"; import { t as resolveSystemBin } from "./resolve-system-bin-CJQrVEby.js"; import { t as basenameFromAnyPath } from "./file-name-D1nUHSBH.js"; import "./image-ops-PfTOpg74.js"; import path from "node:path"; import { execFile, spawn } from "node:child_process"; import { promisify } from "node:util"; //#region src/media/ffmpeg-limits.ts /** Shared stdout/stderr buffer cap for ffmpeg and ffprobe child processes. */ const MEDIA_FFMPEG_MAX_BUFFER_BYTES = 10 * 1024 * 1024; /** Default ffprobe timeout for lightweight metadata probes. */ const MEDIA_FFPROBE_TIMEOUT_MS = 1e4; /** Default ffmpeg timeout for bounded media conversion work. */ const MEDIA_FFMPEG_TIMEOUT_MS = 45e3; /** Maximum audio duration accepted by ffmpeg-backed media flows. */ const MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS = 1200; //#endregion //#region src/media/ffmpeg-exec.ts const execFileAsync = promisify(execFile); function resolveExecOptions(defaultTimeoutMs, options) { return { timeout: options?.timeoutMs ?? defaultTimeoutMs, maxBuffer: options?.maxBufferBytes ?? 10485760 }; } function requireSystemBin(name) { const resolved = resolveSystemBin(name, { trust: "standard" }); if (!resolved) { const hint = process.platform === "darwin" ? "e.g. brew install ffmpeg" : "e.g. apt install ffmpeg / dnf install ffmpeg"; throw new Error(`${name} not found in trusted system directories. Install it via your system package manager (${hint}).`); } return resolved; } /** Resolves ffmpeg from trusted system paths before command execution. */ function resolveFfmpegBin() { return requireSystemBin("ffmpeg"); } function isBrokenPipeError(error) { return error.code === "EPIPE"; } /** Runs ffprobe with optional stdin input, ignoring benign stdin EPIPE after successful output. */ async function runFfprobe(args, options) { const execOptions = resolveExecOptions(MEDIA_FFPROBE_TIMEOUT_MS, options); if (options?.input == null) { const { stdout } = await execFileAsync(requireSystemBin("ffprobe"), args, execOptions); return stdout.toString(); } return await new Promise((resolve, reject) => { let stdinWriteError; const proc = execFile(requireSystemBin("ffprobe"), args, execOptions, (err, stdout) => { if (err) { reject(toLintErrorObject(err, "Non-Error rejection")); return; } if (stdinWriteError && !isBrokenPipeError(stdinWriteError)) { reject(stdinWriteError); return; } resolve(stdout.toString()); }); proc.stdin?.once("error", (err) => { stdinWriteError = err; }); proc.stdin?.end(options.input); }); } /** Runs ffmpeg with bounded timeout and buffer settings. */ async function runFfmpeg(args, options) { const { stdout } = await execFileAsync(resolveFfmpegBin(), args, resolveExecOptions(MEDIA_FFMPEG_TIMEOUT_MS, options)); return stdout.toString(); } /** Splits ffprobe CSV-ish output into normalized lowercase fields. */ function parseFfprobeCsvFields(stdout, maxFields) { return stdout.trim().split(/[,\r\n]+/, maxFields).map((field) => normalizeLowercaseStringOrEmpty(field)); } function parseFfprobeSampleRateHz(value) { if (!value || !/^\d+$/.test(value)) return null; const sampleRate = Number(value); return Number.isSafeInteger(sampleRate) && sampleRate > 0 ? sampleRate : null; } /** Parses codec and positive sample rate from compact ffprobe stream output. */ function parseFfprobeCodecAndSampleRate(stdout) { const [codecRaw, sampleRateRaw] = parseFfprobeCsvFields(stdout, 2); return { codec: codecRaw ? codecRaw : null, sampleRateHz: parseFfprobeSampleRateHz(sampleRateRaw) }; } function toLintErrorObject(value, fallbackMessage) { if (value instanceof Error) return value; if (typeof value === "string") return new Error(value); const error = new Error(fallbackMessage, { cause: value }); if (typeof value === "object" && value !== null || typeof value === "function") Object.assign(error, value); return error; } //#endregion //#region src/media/audio-transcode.ts const DEFAULT_OPUS_SAMPLE_RATE_HZ = 48e3; const DEFAULT_OPUS_BITRATE = "64k"; const DEFAULT_OPUS_CHANNELS = 1; const DEFAULT_TEMP_PREFIX = "audio-opus-"; const DEFAULT_OUTPUT_FILE_NAME = "voice.opus"; function normalizeAudioExtension(params) { const fromExtension = params.inputExtension?.trim(); const normalized = (fromExtension ? fromExtension.startsWith(".") ? fromExtension : `.${fromExtension}` : path.extname(params.inputFileName ?? "")).toLowerCase(); return /^\.[a-z0-9]{1,12}$/.test(normalized) ? normalized : ".audio"; } function normalizeTempPrefix(value) { const sanitized = value?.trim().replace(/[^a-zA-Z0-9._-]/g, "-"); if (!sanitized || sanitized === "." || sanitized === "..") return DEFAULT_TEMP_PREFIX; return sanitized.endsWith("-") ? sanitized : `${sanitized}-`; } function normalizeOutputFileName(value) { const baseName = basenameFromAnyPath(value?.trim() || DEFAULT_OUTPUT_FILE_NAME); if (/^[a-zA-Z0-9._-]{1,80}$/.test(baseName) && baseName !== "." && baseName !== "..") return baseName; return DEFAULT_OUTPUT_FILE_NAME; } /** Transcodes arbitrary audio input into mono Opus using a scoped temp workspace. */ async function transcodeAudioBufferToOpus(params) { return await withTempWorkspace({ rootDir: resolvePreferredOpenClawTmpDir(), prefix: normalizeTempPrefix(params.tempPrefix) }, async (workspace) => { const inputPath = await workspace.write(`input${normalizeAudioExtension(params)}`, params.audioBuffer); const outputFileName = normalizeOutputFileName(params.outputFileName); await writeExternalFileWithinRoot({ rootDir: workspace.dir, path: outputFileName, write: async (outputPath) => { await runFfmpeg([ "-hide_banner", "-loglevel", "error", "-y", "-i", inputPath, "-vn", "-sn", "-dn", "-c:a", "libopus", "-b:a", params.bitrate ?? DEFAULT_OPUS_BITRATE, "-ar", String(params.sampleRateHz ?? DEFAULT_OPUS_SAMPLE_RATE_HZ), "-ac", String(params.channels ?? DEFAULT_OPUS_CHANNELS), "-f", "opus", outputPath ], { timeoutMs: params.timeoutMs }); } }); return await workspace.read(outputFileName); }); } /** Transcodes known audio container pairs, currently using macOS afconvert recipes where needed. */ async function transcodeAudioBuffer(params) { const source = normalizeContainerExt(params.sourceExtension); const target = normalizeContainerExt(params.targetExtension); if (!source || !target) return { ok: false, reason: "invalid-extension" }; if (source === target) return { ok: false, reason: "noop-same-container" }; const recipe = pickAfconvertRecipe(source, target); if (!recipe) return { ok: false, reason: "no-recipe" }; if (process.platform !== "darwin") return { ok: false, reason: "platform-unsupported" }; const tmp = tempWorkspaceSync({ rootDir: resolvePreferredOpenClawTmpDir(), prefix: "tts-transcode-" }); const inPath = tmp.write(`in.${source}`, params.audioBuffer); const outPath = tmp.path(`out.${target}`); try { const result = await runAfconvert({ args: [ ...recipe, inPath, outPath ], timeoutMs: params.timeoutMs ?? 5e3 }); if (!result.ok) return { ok: false, reason: "transcoder-failed", detail: result.detail }; return { ok: true, buffer: tmp.read(`out.${target}`) }; } catch (err) { return { ok: false, reason: "transcoder-failed", detail: err.message }; } finally { tmp.cleanup(); } } function normalizeContainerExt(ext) { const trimmed = ext.trim().toLowerCase().replace(/^\./, ""); return /^[a-z0-9]{1,12}$/.test(trimmed) ? trimmed : void 0; } function pickAfconvertRecipe(_source, target) { if (target === "caf") return [ "-f", "caff", "-d", "opus@24000", "-c", "1" ]; } function runAfconvert(params) { return new Promise((resolve) => { const child = spawn("/usr/bin/afconvert", params.args, { stdio: "ignore" }); const timer = setTimeout(() => { child.kill("SIGKILL"); resolve({ ok: false, detail: `timeout-${params.timeoutMs}ms` }); }, params.timeoutMs); child.once("error", (err) => { clearTimeout(timer); resolve({ ok: false, detail: err.message }); }); child.once("exit", (code) => { clearTimeout(timer); resolve(code === 0 ? { ok: true } : { ok: false, detail: `exit-${code ?? "unknown"}` }); }); }); } //#endregion //#region src/media/video-dimensions.ts function parsePositiveDimension(value) { if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) return; return value; } /** Parses ffprobe JSON output, accepting only positive integer first-stream dimensions. */ function parseFfprobeVideoDimensions(stdout) { const stream = JSON.parse(stdout).streams?.[0]; const width = parsePositiveDimension(stream?.width); const height = parsePositiveDimension(stream?.height); return width && height ? { width, height } : void 0; } /** Probes a video buffer through ffprobe stdin and treats probe failures as unknown dimensions. */ async function probeVideoDimensions(buffer) { try { return parseFfprobeVideoDimensions(await runFfprobe([ "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "json", "pipe:0" ], { input: buffer })); } catch { return; } } //#endregion export { parseFfprobeCodecAndSampleRate as a, runFfmpeg as c, MEDIA_FFMPEG_MAX_BUFFER_BYTES as d, MEDIA_FFMPEG_TIMEOUT_MS as f, transcodeAudioBufferToOpus as i, runFfprobe as l, probeVideoDimensions as n, parseFfprobeCsvFields as o, MEDIA_FFPROBE_TIMEOUT_MS as p, transcodeAudioBuffer as r, resolveFfmpegBin as s, parseFfprobeVideoDimensions as t, MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS as u };