mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +00:00
refactor: stage external output writes through fs-safe
This commit is contained in:
@@ -1,13 +1,15 @@
|
|||||||
import { writeViaSiblingTempPath as writeViaSiblingTempPathBase } from "../sdk-security-runtime.js";
|
import fs from "node:fs/promises";
|
||||||
|
import { writeExternalFileWithinRoot } from "../sdk-security-runtime.js";
|
||||||
|
|
||||||
export async function writeViaSiblingTempPath(params: {
|
export async function writeViaSiblingTempPath(params: {
|
||||||
rootDir: string;
|
rootDir: string;
|
||||||
targetPath: string;
|
targetPath: string;
|
||||||
writeTemp: (tempPath: string) => Promise<void>;
|
writeTemp: (tempPath: string) => Promise<void>;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await writeViaSiblingTempPathBase({
|
await fs.mkdir(params.rootDir, { recursive: true });
|
||||||
...params,
|
await writeExternalFileWithinRoot({
|
||||||
fallbackFileName: "output.bin",
|
rootDir: params.rootDir,
|
||||||
tempPrefix: ".openclaw-output-",
|
path: params.targetPath,
|
||||||
|
write: params.writeTemp,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,11 +138,19 @@ describe("pw-session role refs cache", () => {
|
|||||||
describe("pw-session ensurePageState", () => {
|
describe("pw-session ensurePageState", () => {
|
||||||
it("stores unmanaged downloads under unique managed paths", async () => {
|
it("stores unmanaged downloads under unique managed paths", async () => {
|
||||||
const { page, handlers } = fakePage();
|
const { page, handlers } = fakePage();
|
||||||
const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
|
const mkdirActual = fs.mkdir.bind(fs);
|
||||||
|
const mkdirSpy = vi.spyOn(fs, "mkdir").mockImplementation(async (target, options) => {
|
||||||
|
await mkdirActual(target, options);
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
ensurePageState(page);
|
ensurePageState(page);
|
||||||
|
|
||||||
const saveAsA = vi.fn(async () => {});
|
const saveAsA = vi.fn(async (outPath: string) => {
|
||||||
const saveAsB = vi.fn(async () => {});
|
await fs.writeFile(outPath, "download-a", "utf8");
|
||||||
|
});
|
||||||
|
const saveAsB = vi.fn(async (outPath: string) => {
|
||||||
|
await fs.writeFile(outPath, "download-b", "utf8");
|
||||||
|
});
|
||||||
const downloadA: MutableDownload = {
|
const downloadA: MutableDownload = {
|
||||||
suggestedFilename: () => "report.pdf",
|
suggestedFilename: () => "report.pdf",
|
||||||
saveAs: saveAsA,
|
saveAs: saveAsA,
|
||||||
@@ -163,8 +171,10 @@ describe("pw-session ensurePageState", () => {
|
|||||||
expect(path.dirname(managedPathB ?? "")).toBe(DEFAULT_DOWNLOAD_DIR);
|
expect(path.dirname(managedPathB ?? "")).toBe(DEFAULT_DOWNLOAD_DIR);
|
||||||
expect(path.basename(managedPathA ?? "")).toMatch(/-report\.pdf$/);
|
expect(path.basename(managedPathA ?? "")).toMatch(/-report\.pdf$/);
|
||||||
expect(path.basename(managedPathB ?? "")).toMatch(/-report\.pdf$/);
|
expect(path.basename(managedPathB ?? "")).toMatch(/-report\.pdf$/);
|
||||||
expect(saveAsA).toHaveBeenCalledWith(managedPathA);
|
expect(saveAsA.mock.calls[0]?.[0]).not.toBe(managedPathA);
|
||||||
expect(saveAsB).toHaveBeenCalledWith(managedPathB);
|
expect(saveAsB.mock.calls[0]?.[0]).not.toBe(managedPathB);
|
||||||
|
await expect(fs.readFile(managedPathA ?? "", "utf8")).resolves.toBe("download-a");
|
||||||
|
await expect(fs.readFile(managedPathB ?? "", "utf8")).resolves.toBe("download-b");
|
||||||
expect(mkdirSpy).toHaveBeenCalledWith(DEFAULT_DOWNLOAD_DIR, { recursive: true });
|
expect(mkdirSpy).toHaveBeenCalledWith(DEFAULT_DOWNLOAD_DIR, { recursive: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||||
import type {
|
import type {
|
||||||
@@ -34,6 +33,7 @@ import {
|
|||||||
InvalidBrowserNavigationUrlError,
|
InvalidBrowserNavigationUrlError,
|
||||||
withBrowserNavigationPolicy,
|
withBrowserNavigationPolicy,
|
||||||
} from "./navigation-guard.js";
|
} from "./navigation-guard.js";
|
||||||
|
import { writeViaSiblingTempPath } from "./output-atomic.js";
|
||||||
import { DEFAULT_DOWNLOAD_DIR } from "./paths.js";
|
import { DEFAULT_DOWNLOAD_DIR } from "./paths.js";
|
||||||
import { playwrightCore } from "./playwright-core.runtime.js";
|
import { playwrightCore } from "./playwright-core.runtime.js";
|
||||||
import { BROWSER_REF_MARKER_ATTRIBUTE, withPageScopedCdpClient } from "./pw-session.page-cdp.js";
|
import { BROWSER_REF_MARKER_ATTRIBUTE, withPageScopedCdpClient } from "./pw-session.page-cdp.js";
|
||||||
@@ -466,8 +466,13 @@ export function ensurePageState(page: Page): PageState {
|
|||||||
);
|
);
|
||||||
const managedPath = buildManagedDownloadPath(suggested);
|
const managedPath = buildManagedDownloadPath(suggested);
|
||||||
const managedSave = (async () => {
|
const managedSave = (async () => {
|
||||||
await fs.mkdir(DEFAULT_DOWNLOAD_DIR, { recursive: true });
|
await writeViaSiblingTempPath({
|
||||||
await download.saveAs?.(managedPath);
|
rootDir: DEFAULT_DOWNLOAD_DIR,
|
||||||
|
targetPath: managedPath,
|
||||||
|
writeTemp: async (tempPath) => {
|
||||||
|
await download.saveAs?.(tempPath);
|
||||||
|
},
|
||||||
|
});
|
||||||
return managedPath;
|
return managedPath;
|
||||||
})();
|
})();
|
||||||
managedSave.catch(() => {});
|
managedSave.catch(() => {});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { Page } from "playwright-core";
|
import type { Page } from "playwright-core";
|
||||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||||
@@ -93,10 +92,15 @@ async function saveDownloadPayload(download: DownloadPayload, outPath: string) {
|
|||||||
const suggested = download.suggestedFilename?.() || "download.bin";
|
const suggested = download.suggestedFilename?.() || "download.bin";
|
||||||
const requestedPath = outPath?.trim();
|
const requestedPath = outPath?.trim();
|
||||||
const resolvedOutPath = path.resolve(requestedPath || buildTempDownloadPath(suggested));
|
const resolvedOutPath = path.resolve(requestedPath || buildTempDownloadPath(suggested));
|
||||||
await fs.mkdir(path.dirname(resolvedOutPath), { recursive: true });
|
|
||||||
|
|
||||||
if (!requestedPath) {
|
if (!requestedPath) {
|
||||||
await download.saveAs?.(resolvedOutPath);
|
await writeViaSiblingTempPath({
|
||||||
|
rootDir: path.dirname(resolvedOutPath),
|
||||||
|
targetPath: resolvedOutPath,
|
||||||
|
writeTemp: async (tempPath) => {
|
||||||
|
await download.saveAs?.(tempPath);
|
||||||
|
},
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await writeViaSiblingTempPath({
|
await writeViaSiblingTempPath({
|
||||||
rootDir: path.dirname(resolvedOutPath),
|
rootDir: path.dirname(resolvedOutPath),
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ describe("pw-tools-core", () => {
|
|||||||
suggestedFilename: string;
|
suggestedFilename: string;
|
||||||
}) {
|
}) {
|
||||||
const harness = createDownloadEventHarness();
|
const harness = createDownloadEventHarness();
|
||||||
const saveAs = vi.fn(async () => {});
|
const saveAs = vi.fn(async (outPath: string) => {
|
||||||
|
await fs.writeFile(outPath, "download-content", "utf8");
|
||||||
|
});
|
||||||
|
|
||||||
const p = mod.waitForDownloadViaPlaywright({
|
const p = mod.waitForDownloadViaPlaywright({
|
||||||
cdpUrl: "http://127.0.0.1:18792",
|
cdpUrl: "http://127.0.0.1:18792",
|
||||||
@@ -135,8 +137,7 @@ describe("pw-tools-core", () => {
|
|||||||
const savedPath = params.saveAs.mock.calls[0]?.[0];
|
const savedPath = params.saveAs.mock.calls[0]?.[0];
|
||||||
expect(typeof savedPath).toBe("string");
|
expect(typeof savedPath).toBe("string");
|
||||||
expect(savedPath).not.toBe(params.targetPath);
|
expect(savedPath).not.toBe(params.targetPath);
|
||||||
expect(path.basename(String(savedPath))).toContain(".openclaw-output-");
|
expect(path.basename(String(savedPath))).toBe(path.basename(params.targetPath));
|
||||||
expect(path.basename(String(savedPath))).toContain(".part");
|
|
||||||
expect(await fs.readFile(params.targetPath, "utf8")).toBe(params.content);
|
expect(await fs.readFile(params.targetPath, "utf8")).toBe(params.content);
|
||||||
await expect(fs.access(String(savedPath))).rejects.toThrow();
|
await expect(fs.access(String(savedPath))).rejects.toThrow();
|
||||||
}
|
}
|
||||||
@@ -189,7 +190,9 @@ describe("pw-tools-core", () => {
|
|||||||
harness.trigger({
|
harness.trigger({
|
||||||
url: () => "https://example.com/file.bin",
|
url: () => "https://example.com/file.bin",
|
||||||
suggestedFilename: () => "file.bin",
|
suggestedFilename: () => "file.bin",
|
||||||
saveAs: vi.fn(async () => {}),
|
saveAs: vi.fn(async (outPath: string) => {
|
||||||
|
await fs.writeFile(outPath, "file-content", "utf8");
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await p;
|
await p;
|
||||||
@@ -279,8 +282,9 @@ describe("pw-tools-core", () => {
|
|||||||
path.join(path.sep, "tmp", "openclaw-preferred", "downloads"),
|
path.join(path.sep, "tmp", "openclaw-preferred", "downloads"),
|
||||||
);
|
);
|
||||||
const expectedDownloadsTail = `${path.join("tmp", "openclaw-preferred", "downloads")}${path.sep}`;
|
const expectedDownloadsTail = `${path.join("tmp", "openclaw-preferred", "downloads")}${path.sep}`;
|
||||||
expect(path.dirname(outPath)).toBe(expectedRootedDownloadsDir);
|
expect(path.dirname(res.path)).toBe(expectedRootedDownloadsDir);
|
||||||
expect(path.basename(outPath)).toMatch(/-file\.bin$/);
|
expect(path.basename(outPath)).toBe(path.basename(res.path));
|
||||||
|
await expect(fs.readFile(res.path, "utf8")).resolves.toBe("download-content");
|
||||||
expect(path.normalize(res.path)).toContain(path.normalize(expectedDownloadsTail));
|
expect(path.normalize(res.path)).toContain(path.normalize(expectedDownloadsTail));
|
||||||
expect(tmpDirMocks.resolvePreferredOpenClawTmpDir).toHaveBeenCalled();
|
expect(tmpDirMocks.resolvePreferredOpenClawTmpDir).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -292,10 +296,11 @@ describe("pw-tools-core", () => {
|
|||||||
suggestedFilename: "../../../../etc/passwd",
|
suggestedFilename: "../../../../etc/passwd",
|
||||||
});
|
});
|
||||||
expect(typeof outPath).toBe("string");
|
expect(typeof outPath).toBe("string");
|
||||||
expect(path.dirname(outPath)).toBe(
|
expect(path.dirname(res.path)).toBe(
|
||||||
path.resolve(path.join(path.sep, "tmp", "openclaw-preferred", "downloads")),
|
path.resolve(path.join(path.sep, "tmp", "openclaw-preferred", "downloads")),
|
||||||
);
|
);
|
||||||
expect(path.basename(outPath)).toMatch(/-passwd$/);
|
expect(path.basename(outPath)).toBe(path.basename(res.path));
|
||||||
|
await expect(fs.readFile(res.path, "utf8")).resolves.toBe("download-content");
|
||||||
expect(path.normalize(res.path)).toContain(
|
expect(path.normalize(res.path)).toContain(
|
||||||
path.normalize(`${path.join("tmp", "openclaw-preferred", "downloads")}${path.sep}`),
|
path.normalize(`${path.join("tmp", "openclaw-preferred", "downloads")}${path.sep}`),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export {
|
|||||||
resolveWritablePathWithinRoot,
|
resolveWritablePathWithinRoot,
|
||||||
FsSafeError,
|
FsSafeError,
|
||||||
SsrFBlockedError,
|
SsrFBlockedError,
|
||||||
|
writeExternalFileWithinRoot,
|
||||||
writeViaSiblingTempPath,
|
writeViaSiblingTempPath,
|
||||||
wrapExternalContent,
|
wrapExternalContent,
|
||||||
} from "openclaw/plugin-sdk/security-runtime";
|
} from "openclaw/plugin-sdk/security-runtime";
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { constants as fsConstants } from "node:fs";
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||||
|
import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
|
||||||
import { chromium } from "playwright-core";
|
import { chromium } from "playwright-core";
|
||||||
import type { OpenClawConfig } from "../api.js";
|
import type { OpenClawConfig } from "../api.js";
|
||||||
import type { DiffRenderOptions, DiffTheme } from "./types.js";
|
import type { DiffRenderOptions, DiffTheme } from "./types.js";
|
||||||
@@ -61,7 +62,6 @@ export class PlaywrightDiffScreenshotter implements DiffScreenshotter {
|
|||||||
theme: DiffTheme;
|
theme: DiffTheme;
|
||||||
image: DiffRenderOptions["image"];
|
image: DiffRenderOptions["image"];
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
await fs.mkdir(path.dirname(params.outputPath), { recursive: true });
|
|
||||||
const lease = await acquireSharedBrowser({
|
const lease = await acquireSharedBrowser({
|
||||||
config: this.config,
|
config: this.config,
|
||||||
idleMs: this.browserIdleMs,
|
idleMs: this.browserIdleMs,
|
||||||
@@ -189,16 +189,22 @@ export class PlaywrightDiffScreenshotter implements DiffScreenshotter {
|
|||||||
throw new Error(IMAGE_SIZE_LIMIT_ERROR);
|
throw new Error(IMAGE_SIZE_LIMIT_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.pdf({
|
const pageForPdf = page;
|
||||||
path: params.outputPath,
|
await writeExternalArtifactFile({
|
||||||
width: `${pdfWidth}px`,
|
outputPath: params.outputPath,
|
||||||
height: `${pdfHeight}px`,
|
write: async (tempPath) => {
|
||||||
printBackground: true,
|
await pageForPdf.pdf({
|
||||||
margin: {
|
path: tempPath,
|
||||||
top: "0",
|
width: `${pdfWidth}px`,
|
||||||
right: "0",
|
height: `${pdfHeight}px`,
|
||||||
bottom: "0",
|
printBackground: true,
|
||||||
left: "0",
|
margin: {
|
||||||
|
top: "0",
|
||||||
|
right: "0",
|
||||||
|
bottom: "0",
|
||||||
|
left: "0",
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return params.outputPath;
|
return params.outputPath;
|
||||||
@@ -238,15 +244,21 @@ export class PlaywrightDiffScreenshotter implements DiffScreenshotter {
|
|||||||
throw new Error(IMAGE_SIZE_LIMIT_ERROR);
|
throw new Error(IMAGE_SIZE_LIMIT_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.screenshot({
|
const pageForScreenshot = page;
|
||||||
path: params.outputPath,
|
await writeExternalArtifactFile({
|
||||||
type: "png",
|
outputPath: params.outputPath,
|
||||||
scale: "device",
|
write: async (tempPath) => {
|
||||||
clip: {
|
await pageForScreenshot.screenshot({
|
||||||
x,
|
path: tempPath,
|
||||||
y,
|
type: "png",
|
||||||
width: cssWidth,
|
scale: "device",
|
||||||
height: cssHeight,
|
clip: {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: cssWidth,
|
||||||
|
height: cssHeight,
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return params.outputPath;
|
return params.outputPath;
|
||||||
@@ -268,6 +280,19 @@ export class PlaywrightDiffScreenshotter implements DiffScreenshotter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function writeExternalArtifactFile(params: {
|
||||||
|
outputPath: string;
|
||||||
|
write: (tempPath: string) => Promise<void>;
|
||||||
|
}): Promise<void> {
|
||||||
|
const rootDir = path.dirname(params.outputPath);
|
||||||
|
await fs.mkdir(rootDir, { recursive: true });
|
||||||
|
await writeExternalFileWithinRoot({
|
||||||
|
rootDir,
|
||||||
|
path: path.basename(params.outputPath),
|
||||||
|
write: params.write,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function resetSharedBrowserStateForTests(): Promise<void> {
|
export async function resetSharedBrowserStateForTests(): Promise<void> {
|
||||||
executablePathCache = null;
|
executablePathCache = null;
|
||||||
await closeSharedBrowser();
|
await closeSharedBrowser();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { RequestClient } from "./internal/discord.js";
|
import type { RequestClient } from "./internal/discord.js";
|
||||||
@@ -71,7 +72,13 @@ describe("ensureOggOpus", () => {
|
|||||||
|
|
||||||
it("re-encodes .ogg opus when sample rate is not 48kHz", async () => {
|
it("re-encodes .ogg opus when sample rate is not 48kHz", async () => {
|
||||||
runFfprobeMock.mockResolvedValueOnce("opus,24000\n");
|
runFfprobeMock.mockResolvedValueOnce("opus,24000\n");
|
||||||
runFfmpegMock.mockResolvedValueOnce();
|
runFfmpegMock.mockImplementationOnce(async (args: string[]) => {
|
||||||
|
const outputPath = args.at(-1);
|
||||||
|
if (typeof outputPath !== "string") {
|
||||||
|
throw new Error("missing ffmpeg output path");
|
||||||
|
}
|
||||||
|
await fs.writeFile(outputPath, "ogg");
|
||||||
|
});
|
||||||
|
|
||||||
const result = await ensureOggOpus("/tmp/input.ogg");
|
const result = await ensureOggOpus("/tmp/input.ogg");
|
||||||
|
|
||||||
@@ -79,20 +86,34 @@ describe("ensureOggOpus", () => {
|
|||||||
expect(path.dirname(result.path)).toBe(path.normalize("/tmp"));
|
expect(path.dirname(result.path)).toBe(path.normalize("/tmp"));
|
||||||
expect(path.basename(result.path)).toMatch(/^voice-.*\.ogg$/);
|
expect(path.basename(result.path)).toMatch(/^voice-.*\.ogg$/);
|
||||||
expect(runFfmpegMock).toHaveBeenCalledWith(
|
expect(runFfmpegMock).toHaveBeenCalledWith(
|
||||||
expect.arrayContaining(["-t", "1200", "-ar", "48000", "/tmp/input.ogg", result.path]),
|
expect.arrayContaining(["-t", "1200", "-ar", "48000", "/tmp/input.ogg"]),
|
||||||
);
|
);
|
||||||
|
const ffmpegOutputPath = (runFfmpegMock.mock.calls[0]?.[0] as string[] | undefined)?.at(-1);
|
||||||
|
expect(ffmpegOutputPath).not.toBe(result.path);
|
||||||
|
expect(path.basename(ffmpegOutputPath ?? "")).toBe(path.basename(result.path));
|
||||||
|
await expect(fs.readFile(result.path, "utf8")).resolves.toBe("ogg");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("re-encodes non-ogg input with bounded ffmpeg execution", async () => {
|
it("re-encodes non-ogg input with bounded ffmpeg execution", async () => {
|
||||||
runFfmpegMock.mockResolvedValueOnce();
|
runFfmpegMock.mockImplementationOnce(async (args: string[]) => {
|
||||||
|
const outputPath = args.at(-1);
|
||||||
|
if (typeof outputPath !== "string") {
|
||||||
|
throw new Error("missing ffmpeg output path");
|
||||||
|
}
|
||||||
|
await fs.writeFile(outputPath, "ogg");
|
||||||
|
});
|
||||||
|
|
||||||
const result = await ensureOggOpus("/tmp/input.mp3");
|
const result = await ensureOggOpus("/tmp/input.mp3");
|
||||||
|
|
||||||
expect(result.cleanup).toBe(true);
|
expect(result.cleanup).toBe(true);
|
||||||
expect(runFfprobeMock).not.toHaveBeenCalled();
|
expect(runFfprobeMock).not.toHaveBeenCalled();
|
||||||
expect(runFfmpegMock).toHaveBeenCalledWith(
|
expect(runFfmpegMock).toHaveBeenCalledWith(
|
||||||
expect.arrayContaining(["-vn", "-sn", "-dn", "/tmp/input.mp3", result.path]),
|
expect.arrayContaining(["-vn", "-sn", "-dn", "/tmp/input.mp3"]),
|
||||||
);
|
);
|
||||||
|
const ffmpegOutputPath = (runFfmpegMock.mock.calls[0]?.[0] as string[] | undefined)?.at(-1);
|
||||||
|
expect(ffmpegOutputPath).not.toBe(result.path);
|
||||||
|
expect(path.basename(ffmpegOutputPath ?? "")).toBe(path.basename(result.path));
|
||||||
|
await expect(fs.readFile(result.path, "utf8")).resolves.toBe("ogg");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS } from "openclaw/plugin-sdk/media-runtime";
|
import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS } from "openclaw/plugin-sdk/media-runtime";
|
||||||
import { unlinkIfExists } from "openclaw/plugin-sdk/media-runtime";
|
import { unlinkIfExists } from "openclaw/plugin-sdk/media-runtime";
|
||||||
import type { RetryRunner } from "openclaw/plugin-sdk/retry-runtime";
|
import type { RetryRunner } from "openclaw/plugin-sdk/retry-runtime";
|
||||||
|
import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
|
||||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||||
@@ -33,6 +34,21 @@ const SUPPRESS_NOTIFICATIONS_FLAG = 1 << 12;
|
|||||||
const WAVEFORM_SAMPLES = 256;
|
const WAVEFORM_SAMPLES = 256;
|
||||||
const DISCORD_OPUS_SAMPLE_RATE_HZ = 48_000;
|
const DISCORD_OPUS_SAMPLE_RATE_HZ = 48_000;
|
||||||
|
|
||||||
|
async function runFfmpegToOutput(params: {
|
||||||
|
outputPath: string;
|
||||||
|
buildArgs: (tempPath: string) => string[];
|
||||||
|
}): Promise<void> {
|
||||||
|
const rootDir = path.dirname(params.outputPath);
|
||||||
|
await fs.mkdir(rootDir, { recursive: true });
|
||||||
|
await writeExternalFileWithinRoot({
|
||||||
|
rootDir,
|
||||||
|
path: path.basename(params.outputPath),
|
||||||
|
write: async (tempPath) => {
|
||||||
|
await runFfmpeg(params.buildArgs(tempPath));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function createRateLimitError(
|
function createRateLimitError(
|
||||||
response: Response,
|
response: Response,
|
||||||
body: { message: string; retry_after: number; global: boolean },
|
body: { message: string; retry_after: number; global: boolean },
|
||||||
@@ -104,25 +120,28 @@ async function generateWaveformFromPcm(filePath: string): Promise<string> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Convert to raw 16-bit signed PCM, mono, 8kHz
|
// Convert to raw 16-bit signed PCM, mono, 8kHz
|
||||||
await runFfmpeg([
|
await runFfmpegToOutput({
|
||||||
"-y",
|
outputPath: tempPcm,
|
||||||
"-i",
|
buildArgs: (outputPath) => [
|
||||||
filePath,
|
"-y",
|
||||||
"-vn",
|
"-i",
|
||||||
"-sn",
|
filePath,
|
||||||
"-dn",
|
"-vn",
|
||||||
"-t",
|
"-sn",
|
||||||
String(MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS),
|
"-dn",
|
||||||
"-f",
|
"-t",
|
||||||
"s16le",
|
String(MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS),
|
||||||
"-acodec",
|
"-f",
|
||||||
"pcm_s16le",
|
"s16le",
|
||||||
"-ac",
|
"-acodec",
|
||||||
"1",
|
"pcm_s16le",
|
||||||
"-ar",
|
"-ac",
|
||||||
"8000",
|
"1",
|
||||||
tempPcm,
|
"-ar",
|
||||||
]);
|
"8000",
|
||||||
|
outputPath,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
const pcmData = await fs.readFile(tempPcm);
|
const pcmData = await fs.readFile(tempPcm);
|
||||||
const samples = new Int16Array(pcmData.buffer, pcmData.byteOffset, pcmData.byteLength / 2);
|
const samples = new Int16Array(pcmData.buffer, pcmData.byteOffset, pcmData.byteLength / 2);
|
||||||
@@ -214,23 +233,26 @@ export async function ensureOggOpus(filePath: string): Promise<{ path: string; c
|
|||||||
const tempDir = resolvePreferredOpenClawTmpDir();
|
const tempDir = resolvePreferredOpenClawTmpDir();
|
||||||
const outputPath = path.join(tempDir, `voice-${crypto.randomUUID()}.ogg`);
|
const outputPath = path.join(tempDir, `voice-${crypto.randomUUID()}.ogg`);
|
||||||
|
|
||||||
await runFfmpeg([
|
await runFfmpegToOutput({
|
||||||
"-y",
|
|
||||||
"-i",
|
|
||||||
filePath,
|
|
||||||
"-vn",
|
|
||||||
"-sn",
|
|
||||||
"-dn",
|
|
||||||
"-t",
|
|
||||||
String(MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS),
|
|
||||||
"-ar",
|
|
||||||
String(DISCORD_OPUS_SAMPLE_RATE_HZ),
|
|
||||||
"-c:a",
|
|
||||||
"libopus",
|
|
||||||
"-b:a",
|
|
||||||
"64k",
|
|
||||||
outputPath,
|
outputPath,
|
||||||
]);
|
buildArgs: (tempPath) => [
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
filePath,
|
||||||
|
"-vn",
|
||||||
|
"-sn",
|
||||||
|
"-dn",
|
||||||
|
"-t",
|
||||||
|
String(MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS),
|
||||||
|
"-ar",
|
||||||
|
String(DISCORD_OPUS_SAMPLE_RATE_HZ),
|
||||||
|
"-c:a",
|
||||||
|
"libopus",
|
||||||
|
"-b:a",
|
||||||
|
"64k",
|
||||||
|
tempPath,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
return { path: outputPath, cleanup: true };
|
return { path: outputPath, cleanup: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type * as Lark from "@larksuiteoapi/node-sdk";
|
|||||||
import type { MessageReceipt } from "openclaw/plugin-sdk/channel-message";
|
import type { MessageReceipt } from "openclaw/plugin-sdk/channel-message";
|
||||||
import { mediaKindFromMime } from "openclaw/plugin-sdk/media-mime";
|
import { mediaKindFromMime } from "openclaw/plugin-sdk/media-mime";
|
||||||
import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "openclaw/plugin-sdk/media-runtime";
|
import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "openclaw/plugin-sdk/media-runtime";
|
||||||
import { readRegularFile } from "openclaw/plugin-sdk/security-runtime";
|
import { readRegularFile, writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
|
||||||
import {
|
import {
|
||||||
resolvePreferredOpenClawTmpDir,
|
resolvePreferredOpenClawTmpDir,
|
||||||
withTempWorkspace,
|
withTempWorkspace,
|
||||||
@@ -757,29 +757,34 @@ async function transcodeToFeishuVoiceOpus(params: {
|
|||||||
const ext = normalizeLowercaseStringOrEmpty(path.extname(params.fileName));
|
const ext = normalizeLowercaseStringOrEmpty(path.extname(params.fileName));
|
||||||
const inputExt = ext && ext.length <= 12 ? ext : ".audio";
|
const inputExt = ext && ext.length <= 12 ? ext : ".audio";
|
||||||
const inputPath = await workspace.write(`input${inputExt}`, params.buffer);
|
const inputPath = await workspace.write(`input${inputExt}`, params.buffer);
|
||||||
const outputPath = workspace.path(FEISHU_VOICE_FILE_NAME);
|
await writeExternalFileWithinRoot({
|
||||||
await runFfmpeg([
|
rootDir: workspace.dir,
|
||||||
"-hide_banner",
|
path: FEISHU_VOICE_FILE_NAME,
|
||||||
"-loglevel",
|
write: async (outputPath) => {
|
||||||
"error",
|
await runFfmpeg([
|
||||||
"-y",
|
"-hide_banner",
|
||||||
"-i",
|
"-loglevel",
|
||||||
inputPath,
|
"error",
|
||||||
"-vn",
|
"-y",
|
||||||
"-sn",
|
"-i",
|
||||||
"-dn",
|
inputPath,
|
||||||
"-t",
|
"-vn",
|
||||||
String(MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS),
|
"-sn",
|
||||||
"-ar",
|
"-dn",
|
||||||
String(FEISHU_VOICE_SAMPLE_RATE_HZ),
|
"-t",
|
||||||
"-ac",
|
String(MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS),
|
||||||
"1",
|
"-ar",
|
||||||
"-c:a",
|
String(FEISHU_VOICE_SAMPLE_RATE_HZ),
|
||||||
"libopus",
|
"-ac",
|
||||||
"-b:a",
|
"1",
|
||||||
FEISHU_VOICE_BITRATE,
|
"-c:a",
|
||||||
outputPath,
|
"libopus",
|
||||||
]);
|
"-b:a",
|
||||||
|
FEISHU_VOICE_BITRATE,
|
||||||
|
outputPath,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
buffer: await workspace.read(FEISHU_VOICE_FILE_NAME),
|
buffer: await workspace.read(FEISHU_VOICE_FILE_NAME),
|
||||||
fileName: FEISHU_VOICE_FILE_NAME,
|
fileName: FEISHU_VOICE_FILE_NAME,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const { createGoogleGenAIMock, downloadMock, generateVideosMock, getVideosOperationMock } =
|
const { createGoogleGenAIMock, downloadMock, generateVideosMock, getVideosOperationMock } =
|
||||||
@@ -195,6 +197,44 @@ describe("google video generation provider", () => {
|
|||||||
expect(result.videos[0]?.mimeType).toBe("video/mp4");
|
expect(result.videos[0]?.mimeType).toBe("video/mp4");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stages SDK file downloads before finalizing generated video bytes", async () => {
|
||||||
|
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
|
||||||
|
apiKey: "google-key",
|
||||||
|
source: "env",
|
||||||
|
mode: "api-key",
|
||||||
|
});
|
||||||
|
generateVideosMock.mockResolvedValue({
|
||||||
|
done: true,
|
||||||
|
response: {
|
||||||
|
generatedVideos: [
|
||||||
|
{
|
||||||
|
video: {
|
||||||
|
name: "files/generated-video",
|
||||||
|
mimeType: "video/mp4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
downloadMock.mockImplementation(async ({ downloadPath }: { downloadPath: string }) => {
|
||||||
|
await writeFile(downloadPath, "sdk-video");
|
||||||
|
});
|
||||||
|
|
||||||
|
const provider = buildGoogleVideoGenerationProvider();
|
||||||
|
const result = await provider.generateVideo({
|
||||||
|
provider: "google",
|
||||||
|
model: "veo-3.1-fast-generate-preview",
|
||||||
|
prompt: "A tiny robot watering a windowsill garden",
|
||||||
|
cfg: {},
|
||||||
|
durationSeconds: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [{ downloadPath }] = downloadMock.mock.calls[0] ?? [{}];
|
||||||
|
expect(path.basename(String(downloadPath))).toBe("video-1.mp4");
|
||||||
|
expect(result.videos[0]?.buffer).toEqual(Buffer.from("sdk-video"));
|
||||||
|
expect(result.videos[0]?.fileName).toBe("video-1.mp4");
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to REST predictLongRunning when text-only SDK video generation returns 404", async () => {
|
it("falls back to REST predictLongRunning when text-only SDK video generation returns 404", async () => {
|
||||||
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
|
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
|
||||||
apiKey: "google-key",
|
apiKey: "google-key",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
resolveProviderOperationTimeoutMs,
|
resolveProviderOperationTimeoutMs,
|
||||||
waitProviderOperationPollInterval,
|
waitProviderOperationPollInterval,
|
||||||
} from "openclaw/plugin-sdk/provider-http";
|
} from "openclaw/plugin-sdk/provider-http";
|
||||||
|
import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
|
||||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||||
import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path";
|
import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path";
|
||||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||||
@@ -154,10 +155,17 @@ async function downloadGeneratedVideo(params: {
|
|||||||
return await withTempWorkspace(
|
return await withTempWorkspace(
|
||||||
{ rootDir: resolvePreferredOpenClawTmpDir(), prefix: "openclaw-google-video-" },
|
{ rootDir: resolvePreferredOpenClawTmpDir(), prefix: "openclaw-google-video-" },
|
||||||
async ({ dir: tempDir }) => {
|
async ({ dir: tempDir }) => {
|
||||||
const downloadPath = path.join(tempDir, `video-${params.index + 1}.mp4`);
|
const fileName = `video-${params.index + 1}.mp4`;
|
||||||
await params.client.files.download({
|
const downloadPath = path.join(tempDir, fileName);
|
||||||
file: params.file as never,
|
await writeExternalFileWithinRoot({
|
||||||
downloadPath,
|
rootDir: tempDir,
|
||||||
|
path: fileName,
|
||||||
|
write: async (downloadPath) => {
|
||||||
|
await params.client.files.download({
|
||||||
|
file: params.file as never,
|
||||||
|
downloadPath,
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const buffer = await readFile(downloadPath);
|
const buffer = await readFile(downloadPath);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
@@ -92,8 +92,10 @@ describe("edgeTTS empty audio validation", () => {
|
|||||||
it("succeeds when the output file has content", async () => {
|
it("succeeds when the output file has content", async () => {
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "tts-test-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "tts-test-"));
|
||||||
const outputPath = path.join(tempDir, "voice.mp3");
|
const outputPath = path.join(tempDir, "voice.mp3");
|
||||||
|
let stagedPath = "";
|
||||||
|
|
||||||
const deps = createEdgeTTSDeps(async (_text: string, filePath: string) => {
|
const deps = createEdgeTTSDeps(async (_text: string, filePath: string) => {
|
||||||
|
stagedPath = filePath;
|
||||||
writeFileSync(filePath, Buffer.from([0xff, 0xfb, 0x90, 0x00]));
|
writeFileSync(filePath, Buffer.from([0xff, 0xfb, 0x90, 0x00]));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,6 +110,10 @@ describe("edgeTTS empty audio validation", () => {
|
|||||||
deps,
|
deps,
|
||||||
),
|
),
|
||||||
).resolves.toBeUndefined();
|
).resolves.toBeUndefined();
|
||||||
|
expect(stagedPath).not.toBe(outputPath);
|
||||||
|
expect(path.basename(stagedPath)).toBe(path.basename(outputPath));
|
||||||
|
expect(readFileSync(outputPath)).toEqual(Buffer.from([0xff, 0xfb, 0x90, 0x00]));
|
||||||
|
expect(existsSync(stagedPath)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("retries once when the first output file is empty", async () => {
|
it("retries once when the first output file is empty", async () => {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { statSync } from "node:fs";
|
import { statSync, writeFileSync } from "node:fs";
|
||||||
|
import { mkdir } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
|
||||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||||
|
|
||||||
type EdgeTTSRuntimeConfig = {
|
type EdgeTTSRuntimeConfig = {
|
||||||
@@ -99,10 +102,36 @@ export async function edgeTTS(
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
await tts.ttsPromise(text, outputPath);
|
const outputSize = await writeEdgeTtsOutput({
|
||||||
if (readOutputSize(outputPath) > 0) {
|
outputPath,
|
||||||
|
ttsPromise: async (tempPath) => {
|
||||||
|
await tts.ttsPromise(text, tempPath);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (outputSize > 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error("Edge TTS produced empty audio file after retry");
|
throw new Error("Edge TTS produced empty audio file after retry");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function writeEdgeTtsOutput(params: {
|
||||||
|
outputPath: string;
|
||||||
|
ttsPromise: (tempPath: string) => Promise<void>;
|
||||||
|
}): Promise<number> {
|
||||||
|
const rootDir = path.dirname(params.outputPath);
|
||||||
|
await mkdir(rootDir, { recursive: true });
|
||||||
|
let outputSize = 0;
|
||||||
|
await writeExternalFileWithinRoot({
|
||||||
|
rootDir,
|
||||||
|
path: path.basename(params.outputPath),
|
||||||
|
write: async (tempPath) => {
|
||||||
|
await params.ttsPromise(tempPath);
|
||||||
|
outputSize = readOutputSize(tempPath);
|
||||||
|
if (outputSize === 0) {
|
||||||
|
writeFileSync(tempPath, "");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return outputSize;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { handleDiscordMessageAction, requestDiscord } from "@openclaw/discord/ap
|
|||||||
import { DEFAULT_EMOJIS } from "openclaw/plugin-sdk/channel-feedback";
|
import { DEFAULT_EMOJIS } from "openclaw/plugin-sdk/channel-feedback";
|
||||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||||
|
import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
|
||||||
import { chromium } from "playwright-core";
|
import { chromium } from "playwright-core";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { startQaGatewayChild } from "../../gateway-child.js";
|
import { startQaGatewayChild } from "../../gateway-child.js";
|
||||||
@@ -710,7 +711,14 @@ async function writeHtmlScreenshot(params: { htmlPath: string; screenshotPath: s
|
|||||||
waitUntil: "domcontentloaded",
|
waitUntil: "domcontentloaded",
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
await page.screenshot({ path: params.screenshotPath, fullPage: true });
|
await fs.mkdir(path.dirname(params.screenshotPath), { recursive: true });
|
||||||
|
await writeExternalFileWithinRoot({
|
||||||
|
rootDir: path.dirname(params.screenshotPath),
|
||||||
|
path: path.basename(params.screenshotPath),
|
||||||
|
write: async (tempPath) => {
|
||||||
|
await page.screenshot({ path: tempPath, fullPage: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
return { screenshotPath: params.screenshotPath };
|
return { screenshotPath: params.screenshotPath };
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|||||||
@@ -79,12 +79,17 @@ describe("mantis visual task runtime", () => {
|
|||||||
["/tmp/crabbox", "stop"],
|
["/tmp/crabbox", "stop"],
|
||||||
]);
|
]);
|
||||||
const recordArgs = commands.find((entry) => entry.args[0] === "record")?.args ?? [];
|
const recordArgs = commands.find((entry) => entry.args[0] === "record")?.args ?? [];
|
||||||
|
const finalVideoPath = path.join(
|
||||||
|
repoRoot,
|
||||||
|
".artifacts/qa-e2e/mantis/visual-task-test/visual-task.mp4",
|
||||||
|
);
|
||||||
|
const stagedVideoPath = recordArgs[recordArgs.indexOf("--output") + 1];
|
||||||
expect(recordArgs).toEqual(
|
expect(recordArgs).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
"--duration",
|
"--duration",
|
||||||
"12s",
|
"12s",
|
||||||
"--output",
|
"--output",
|
||||||
path.join(repoRoot, ".artifacts/qa-e2e/mantis/visual-task-test/visual-task.mp4"),
|
stagedVideoPath,
|
||||||
"--while",
|
"--while",
|
||||||
"--",
|
"--",
|
||||||
"pnpm",
|
"pnpm",
|
||||||
@@ -96,6 +101,9 @@ describe("mantis visual task runtime", () => {
|
|||||||
"visual-driver",
|
"visual-driver",
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
expect(stagedVideoPath).not.toBe(finalVideoPath);
|
||||||
|
expect(path.basename(stagedVideoPath ?? "")).toBe(path.basename(finalVideoPath));
|
||||||
|
await expect(fs.stat(stagedVideoPath ?? "")).rejects.toThrow();
|
||||||
await expect(fs.readFile(result.screenshotPath ?? "", "utf8")).resolves.toBe("png");
|
await expect(fs.readFile(result.screenshotPath ?? "", "utf8")).resolves.toBe("png");
|
||||||
await expect(fs.readFile(result.videoPath ?? "", "utf8")).resolves.toBe("mp4");
|
await expect(fs.readFile(result.videoPath ?? "", "utf8")).resolves.toBe("mp4");
|
||||||
const summary = JSON.parse(await fs.readFile(result.summaryPath, "utf8")) as {
|
const summary = JSON.parse(await fs.readFile(result.summaryPath, "utf8")) as {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { spawn, type SpawnOptions } from "node:child_process";
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||||
import { pathExists } from "openclaw/plugin-sdk/security-runtime";
|
import { pathExists, writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
|
||||||
import { ensureRepoBoundDirectory, resolveRepoRelativeOutputDir } from "../cli-paths.js";
|
import { ensureRepoBoundDirectory, resolveRepoRelativeOutputDir } from "../cli-paths.js";
|
||||||
|
|
||||||
export type MantisVisualTaskVisionMode = "image-describe" | "metadata";
|
export type MantisVisualTaskVisionMode = "image-describe" | "metadata";
|
||||||
@@ -285,6 +285,30 @@ async function runCommand(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runCommandWithExternalOutput(params: {
|
||||||
|
outputPath: string;
|
||||||
|
buildArgs: (tempPath: string) => readonly string[];
|
||||||
|
command: string;
|
||||||
|
cwd: string;
|
||||||
|
env: NodeJS.ProcessEnv;
|
||||||
|
runner: CommandRunner;
|
||||||
|
stdio?: "inherit" | "pipe";
|
||||||
|
}) {
|
||||||
|
return await writeExternalFileWithinRoot({
|
||||||
|
rootDir: path.dirname(params.outputPath),
|
||||||
|
path: path.basename(params.outputPath),
|
||||||
|
write: async (tempPath) =>
|
||||||
|
await runCommand({
|
||||||
|
command: params.command,
|
||||||
|
args: params.buildArgs(tempPath),
|
||||||
|
cwd: params.cwd,
|
||||||
|
env: params.env,
|
||||||
|
runner: params.runner,
|
||||||
|
stdio: params.stdio,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function warmupCrabbox(params: {
|
async function warmupCrabbox(params: {
|
||||||
crabboxBin: string;
|
crabboxBin: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -629,16 +653,17 @@ export async function runMantisVisualDriver(
|
|||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
});
|
});
|
||||||
await new Promise((resolve) => setTimeout(resolve, opts.settleMs ?? DEFAULT_SETTLE_MS));
|
await new Promise((resolve) => setTimeout(resolve, opts.settleMs ?? DEFAULT_SETTLE_MS));
|
||||||
await runCommand({
|
await runCommandWithExternalOutput({
|
||||||
command: crabboxBin,
|
command: crabboxBin,
|
||||||
args: [
|
outputPath: screenshotPath,
|
||||||
|
buildArgs: (tempPath) => [
|
||||||
"screenshot",
|
"screenshot",
|
||||||
"--provider",
|
"--provider",
|
||||||
provider,
|
provider,
|
||||||
"--id",
|
"--id",
|
||||||
leaseId,
|
leaseId,
|
||||||
"--output",
|
"--output",
|
||||||
screenshotPath,
|
tempPath,
|
||||||
"--reclaim",
|
"--reclaim",
|
||||||
],
|
],
|
||||||
cwd: repoRoot,
|
cwd: repoRoot,
|
||||||
@@ -777,19 +802,24 @@ export async function runMantisVisualTask(
|
|||||||
runner,
|
runner,
|
||||||
});
|
});
|
||||||
let recordingError: string | undefined;
|
let recordingError: string | undefined;
|
||||||
|
const activeLeaseId = leaseId;
|
||||||
|
if (!activeLeaseId) {
|
||||||
|
throw new Error("Crabbox lease id missing after warmup.");
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await runCommand({
|
await runCommandWithExternalOutput({
|
||||||
command: crabboxBin,
|
command: crabboxBin,
|
||||||
args: [
|
outputPath: videoPath,
|
||||||
|
buildArgs: (tempPath) => [
|
||||||
"record",
|
"record",
|
||||||
"--provider",
|
"--provider",
|
||||||
provider,
|
provider,
|
||||||
"--id",
|
"--id",
|
||||||
leaseId,
|
activeLeaseId,
|
||||||
"--duration",
|
"--duration",
|
||||||
trimToValue(opts.duration) ?? DEFAULT_DURATION,
|
trimToValue(opts.duration) ?? DEFAULT_DURATION,
|
||||||
"--output",
|
"--output",
|
||||||
videoPath,
|
tempPath,
|
||||||
"--while",
|
"--while",
|
||||||
"--",
|
"--",
|
||||||
"pnpm",
|
"pnpm",
|
||||||
@@ -797,7 +827,7 @@ export async function runMantisVisualTask(
|
|||||||
browserUrl,
|
browserUrl,
|
||||||
crabboxBin,
|
crabboxBin,
|
||||||
expectText,
|
expectText,
|
||||||
leaseId,
|
leaseId: activeLeaseId,
|
||||||
outputDir,
|
outputDir,
|
||||||
provider,
|
provider,
|
||||||
repoRoot,
|
repoRoot,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { runFfmpeg } from "openclaw/plugin-sdk/media-runtime";
|
import { runFfmpeg } from "openclaw/plugin-sdk/media-runtime";
|
||||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||||
|
import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
|
||||||
import type {
|
import type {
|
||||||
SpeechProviderConfig,
|
SpeechProviderConfig,
|
||||||
SpeechProviderPlugin,
|
SpeechProviderPlugin,
|
||||||
@@ -270,36 +271,50 @@ async function convertAudio(
|
|||||||
outputDir: string,
|
outputDir: string,
|
||||||
target: OutputFormat,
|
target: OutputFormat,
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
const outputPath = path.join(outputDir, `converted${getFileExt(target)}`);
|
const outputFileName = `converted${getFileExt(target)}`;
|
||||||
|
const outputPath = path.join(outputDir, outputFileName);
|
||||||
const args = ["-y", "-i", inputPath];
|
const args = ["-y", "-i", inputPath];
|
||||||
if (target === "opus") {
|
if (target === "opus") {
|
||||||
args.push("-c:a", "libopus", "-b:a", "64k", outputPath);
|
args.push("-c:a", "libopus", "-b:a", "64k");
|
||||||
} else if (target === "wav") {
|
} else if (target === "wav") {
|
||||||
args.push("-c:a", "pcm_s16le", outputPath);
|
args.push("-c:a", "pcm_s16le");
|
||||||
} else {
|
} else {
|
||||||
args.push("-c:a", "libmp3lame", "-b:a", "128k", outputPath);
|
args.push("-c:a", "libmp3lame", "-b:a", "128k");
|
||||||
}
|
}
|
||||||
await runFfmpeg(args);
|
await writeExternalFileWithinRoot({
|
||||||
|
rootDir: outputDir,
|
||||||
|
path: outputFileName,
|
||||||
|
write: async (tempPath) => {
|
||||||
|
await runFfmpeg([...args, tempPath]);
|
||||||
|
},
|
||||||
|
});
|
||||||
return readFileSync(outputPath);
|
return readFileSync(outputPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function convertToRawPcm(inputPath: string, outputDir: string): Promise<Buffer> {
|
async function convertToRawPcm(inputPath: string, outputDir: string): Promise<Buffer> {
|
||||||
// Output raw 16kHz mono 16-bit little-endian PCM (no WAV headers)
|
// Output raw 16kHz mono 16-bit little-endian PCM (no WAV headers)
|
||||||
const outputPath = path.join(outputDir, "telephony.pcm");
|
const outputFileName = "telephony.pcm";
|
||||||
await runFfmpeg([
|
const outputPath = path.join(outputDir, outputFileName);
|
||||||
"-y",
|
await writeExternalFileWithinRoot({
|
||||||
"-i",
|
rootDir: outputDir,
|
||||||
inputPath,
|
path: outputFileName,
|
||||||
"-c:a",
|
write: async (tempPath) => {
|
||||||
"pcm_s16le",
|
await runFfmpeg([
|
||||||
"-ar",
|
"-y",
|
||||||
"16000",
|
"-i",
|
||||||
"-ac",
|
inputPath,
|
||||||
"1",
|
"-c:a",
|
||||||
"-f",
|
"pcm_s16le",
|
||||||
"s16le",
|
"-ar",
|
||||||
outputPath,
|
"16000",
|
||||||
]);
|
"-ac",
|
||||||
|
"1",
|
||||||
|
"-f",
|
||||||
|
"s16le",
|
||||||
|
tempPath,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
return readFileSync(outputPath);
|
return readFileSync(outputPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "openclaw/plugin-sdk/media-runtime";
|
import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "openclaw/plugin-sdk/media-runtime";
|
||||||
import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-runtime";
|
import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-runtime";
|
||||||
|
import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
|
||||||
import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path";
|
import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path";
|
||||||
import { formatError } from "./session-errors.js";
|
import { formatError } from "./session-errors.js";
|
||||||
import {
|
import {
|
||||||
@@ -189,29 +190,34 @@ async function transcodeToWhatsAppVoiceOpus(params: {
|
|||||||
const ext = path.extname(params.fileName).toLowerCase();
|
const ext = path.extname(params.fileName).toLowerCase();
|
||||||
const inputExt = ext && ext.length <= 12 ? ext : ".audio";
|
const inputExt = ext && ext.length <= 12 ? ext : ".audio";
|
||||||
const inputPath = await workspace.write(`input${inputExt}`, params.buffer);
|
const inputPath = await workspace.write(`input${inputExt}`, params.buffer);
|
||||||
const outputPath = workspace.path(WHATSAPP_VOICE_FILE_NAME);
|
await writeExternalFileWithinRoot({
|
||||||
await runFfmpeg([
|
rootDir: workspace.dir,
|
||||||
"-hide_banner",
|
path: WHATSAPP_VOICE_FILE_NAME,
|
||||||
"-loglevel",
|
write: async (outputPath) => {
|
||||||
"error",
|
await runFfmpeg([
|
||||||
"-y",
|
"-hide_banner",
|
||||||
"-i",
|
"-loglevel",
|
||||||
inputPath,
|
"error",
|
||||||
"-vn",
|
"-y",
|
||||||
"-sn",
|
"-i",
|
||||||
"-dn",
|
inputPath,
|
||||||
"-t",
|
"-vn",
|
||||||
String(MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS),
|
"-sn",
|
||||||
"-ar",
|
"-dn",
|
||||||
String(WHATSAPP_VOICE_SAMPLE_RATE_HZ),
|
"-t",
|
||||||
"-ac",
|
String(MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS),
|
||||||
"1",
|
"-ar",
|
||||||
"-c:a",
|
String(WHATSAPP_VOICE_SAMPLE_RATE_HZ),
|
||||||
"libopus",
|
"-ac",
|
||||||
"-b:a",
|
"1",
|
||||||
WHATSAPP_VOICE_BITRATE,
|
"-c:a",
|
||||||
outputPath,
|
"libopus",
|
||||||
]);
|
"-b:a",
|
||||||
|
WHATSAPP_VOICE_BITRATE,
|
||||||
|
outputPath,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
return await workspace.read(WHATSAPP_VOICE_FILE_NAME);
|
return await workspace.read(WHATSAPP_VOICE_FILE_NAME);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1695,7 +1695,7 @@
|
|||||||
"@mariozechner/pi-tui": "0.73.0",
|
"@mariozechner/pi-tui": "0.73.0",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
"@openclaw/fs-safe": "github:openclaw/fs-safe#3412e03c09cdfd31c2da04b7d74e39ad7a92d07d",
|
"@openclaw/fs-safe": "github:openclaw/fs-safe#ce4137f028ca4b09f26a93ffa3e0d32fbfbd516c",
|
||||||
"@slack/bolt": "^4.7.2",
|
"@slack/bolt": "^4.7.2",
|
||||||
"@slack/types": "^2.21.0",
|
"@slack/types": "^2.21.0",
|
||||||
"@slack/web-api": "^7.15.2",
|
"@slack/web-api": "^7.15.2",
|
||||||
|
|||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -105,8 +105,8 @@ importers:
|
|||||||
specifier: ^0.6.0
|
specifier: ^0.6.0
|
||||||
version: 0.6.0
|
version: 0.6.0
|
||||||
'@openclaw/fs-safe':
|
'@openclaw/fs-safe':
|
||||||
specifier: github:openclaw/fs-safe#3412e03c09cdfd31c2da04b7d74e39ad7a92d07d
|
specifier: github:openclaw/fs-safe#ce4137f028ca4b09f26a93ffa3e0d32fbfbd516c
|
||||||
version: https://codeload.github.com/openclaw/fs-safe/tar.gz/3412e03c09cdfd31c2da04b7d74e39ad7a92d07d
|
version: https://codeload.github.com/openclaw/fs-safe/tar.gz/ce4137f028ca4b09f26a93ffa3e0d32fbfbd516c
|
||||||
'@slack/bolt':
|
'@slack/bolt':
|
||||||
specifier: ^4.7.2
|
specifier: ^4.7.2
|
||||||
version: 4.7.2(@types/express@5.0.6)
|
version: 4.7.2(@types/express@5.0.6)
|
||||||
@@ -3234,8 +3234,8 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@openclaw/fs-safe@https://codeload.github.com/openclaw/fs-safe/tar.gz/3412e03c09cdfd31c2da04b7d74e39ad7a92d07d':
|
'@openclaw/fs-safe@https://codeload.github.com/openclaw/fs-safe/tar.gz/ce4137f028ca4b09f26a93ffa3e0d32fbfbd516c':
|
||||||
resolution: {tarball: https://codeload.github.com/openclaw/fs-safe/tar.gz/3412e03c09cdfd31c2da04b7d74e39ad7a92d07d}
|
resolution: {tarball: https://codeload.github.com/openclaw/fs-safe/tar.gz/ce4137f028ca4b09f26a93ffa3e0d32fbfbd516c}
|
||||||
version: 0.1.2
|
version: 0.1.2
|
||||||
engines: {node: '>=20.11'}
|
engines: {node: '>=20.11'}
|
||||||
|
|
||||||
@@ -10004,7 +10004,7 @@ snapshots:
|
|||||||
'@openai/codex@0.128.0-win32-x64':
|
'@openai/codex@0.128.0-win32-x64':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@openclaw/fs-safe@https://codeload.github.com/openclaw/fs-safe/tar.gz/3412e03c09cdfd31c2da04b7d74e39ad7a92d07d':
|
'@openclaw/fs-safe@https://codeload.github.com/openclaw/fs-safe/tar.gz/ce4137f028ca4b09f26a93ffa3e0d32fbfbd516c':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
jszip: 3.10.1
|
jszip: 3.10.1
|
||||||
tar: 7.5.13
|
tar: 7.5.13
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ export {
|
|||||||
type WalkDirectoryResult,
|
type WalkDirectoryResult,
|
||||||
} from "@openclaw/fs-safe/walk";
|
} from "@openclaw/fs-safe/walk";
|
||||||
export { withTimeout } from "@openclaw/fs-safe/advanced";
|
export { withTimeout } from "@openclaw/fs-safe/advanced";
|
||||||
|
export {
|
||||||
|
writeExternalFileWithinRoot,
|
||||||
|
type ExternalFileWriteOptions,
|
||||||
|
type ExternalFileWriteResult,
|
||||||
|
} from "@openclaw/fs-safe/output";
|
||||||
|
|
||||||
/** @deprecated Use root(rootDir).read(relativePath, options). */
|
/** @deprecated Use root(rootDir).read(relativePath, options). */
|
||||||
export async function readFileWithinRoot(params: {
|
export async function readFileWithinRoot(params: {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
MediaUnderstandingModelConfig,
|
MediaUnderstandingModelConfig,
|
||||||
} from "../config/types.tools.js";
|
} from "../config/types.tools.js";
|
||||||
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
||||||
|
import { writeExternalFileWithinRoot } from "../infra/fs-safe.js";
|
||||||
import { resolveProxyFetchFromEnv } from "../infra/net/proxy-fetch.js";
|
import { resolveProxyFetchFromEnv } from "../infra/net/proxy-fetch.js";
|
||||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||||
import { runFfmpeg } from "../media/ffmpeg-exec.js";
|
import { runFfmpeg } from "../media/ffmpeg-exec.js";
|
||||||
@@ -252,18 +253,25 @@ async function resolveCliMediaPath(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const wavPath = path.join(params.outputDir, `${path.parse(params.mediaPath).name}.wav`);
|
const wavPath = path.join(params.outputDir, `${path.parse(params.mediaPath).name}.wav`);
|
||||||
await runFfmpeg([
|
await fs.mkdir(params.outputDir, { recursive: true });
|
||||||
"-y",
|
await writeExternalFileWithinRoot({
|
||||||
"-i",
|
rootDir: params.outputDir,
|
||||||
params.mediaPath,
|
path: path.basename(wavPath),
|
||||||
"-ac",
|
write: async (outputPath) => {
|
||||||
"1",
|
await runFfmpeg([
|
||||||
"-ar",
|
"-y",
|
||||||
"16000",
|
"-i",
|
||||||
"-c:a",
|
params.mediaPath,
|
||||||
"pcm_s16le",
|
"-ac",
|
||||||
wavPath,
|
"1",
|
||||||
]);
|
"-ar",
|
||||||
|
"16000",
|
||||||
|
"-c:a",
|
||||||
|
"pcm_s16le",
|
||||||
|
outputPath,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
return wavPath;
|
return wavPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,6 @@ describe("transcodeAudioBufferToOpus", () => {
|
|||||||
|
|
||||||
const tempRoot = realpathSync(resolvePreferredOpenClawTmpDir());
|
const tempRoot = realpathSync(resolvePreferredOpenClawTmpDir());
|
||||||
expect(capturedInputPath?.startsWith(tempRoot)).toBe(true);
|
expect(capturedInputPath?.startsWith(tempRoot)).toBe(true);
|
||||||
expect(capturedOutputPath?.startsWith(tempRoot)).toBe(true);
|
expect(capturedOutputPath ? existsSync(capturedOutputPath) : true).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { writeExternalFileWithinRoot } from "../infra/fs-safe.js";
|
||||||
import { withTempWorkspace } from "../infra/private-temp-workspace.js";
|
import { withTempWorkspace } from "../infra/private-temp-workspace.js";
|
||||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||||
import { runFfmpeg } from "./ffmpeg-exec.js";
|
import { runFfmpeg } from "./ffmpeg-exec.js";
|
||||||
@@ -60,31 +61,37 @@ export async function transcodeAudioBufferToOpus(params: {
|
|||||||
`input${normalizeAudioExtension(params)}`,
|
`input${normalizeAudioExtension(params)}`,
|
||||||
params.audioBuffer,
|
params.audioBuffer,
|
||||||
);
|
);
|
||||||
const outputPath = workspace.path(normalizeOutputFileName(params.outputFileName));
|
const outputFileName = normalizeOutputFileName(params.outputFileName);
|
||||||
await runFfmpeg(
|
await writeExternalFileWithinRoot({
|
||||||
[
|
rootDir: workspace.dir,
|
||||||
"-hide_banner",
|
path: outputFileName,
|
||||||
"-loglevel",
|
write: async (outputPath) => {
|
||||||
"error",
|
await runFfmpeg(
|
||||||
"-y",
|
[
|
||||||
"-i",
|
"-hide_banner",
|
||||||
inputPath,
|
"-loglevel",
|
||||||
"-vn",
|
"error",
|
||||||
"-sn",
|
"-y",
|
||||||
"-dn",
|
"-i",
|
||||||
"-c:a",
|
inputPath,
|
||||||
"libopus",
|
"-vn",
|
||||||
"-b:a",
|
"-sn",
|
||||||
params.bitrate ?? DEFAULT_OPUS_BITRATE,
|
"-dn",
|
||||||
"-ar",
|
"-c:a",
|
||||||
String(params.sampleRateHz ?? DEFAULT_OPUS_SAMPLE_RATE_HZ),
|
"libopus",
|
||||||
"-ac",
|
"-b:a",
|
||||||
String(params.channels ?? DEFAULT_OPUS_CHANNELS),
|
params.bitrate ?? DEFAULT_OPUS_BITRATE,
|
||||||
outputPath,
|
"-ar",
|
||||||
],
|
String(params.sampleRateHz ?? DEFAULT_OPUS_SAMPLE_RATE_HZ),
|
||||||
{ timeoutMs: params.timeoutMs },
|
"-ac",
|
||||||
);
|
String(params.channels ?? DEFAULT_OPUS_CHANNELS),
|
||||||
return await workspace.read(normalizeOutputFileName(params.outputFileName));
|
outputPath,
|
||||||
|
],
|
||||||
|
{ timeoutMs: params.timeoutMs },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return await workspace.read(outputFileName);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ export {
|
|||||||
resolveRegularFileAppendFlags,
|
resolveRegularFileAppendFlags,
|
||||||
root,
|
root,
|
||||||
statRegularFileSync,
|
statRegularFileSync,
|
||||||
|
writeExternalFileWithinRoot,
|
||||||
withTimeout,
|
withTimeout,
|
||||||
|
type ExternalFileWriteOptions,
|
||||||
|
type ExternalFileWriteResult,
|
||||||
type FsSafeErrorCode as SafeOpenErrorCode,
|
type FsSafeErrorCode as SafeOpenErrorCode,
|
||||||
} from "../infra/fs-safe.js";
|
} from "../infra/fs-safe.js";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user