Skip to content

Commit 7921b6d

Browse files
committed
feat(mcp): export a project's audio, or one track alone, to WAV or M4A
exportAudio on both MCP servers, and wav/m4a outputs with --track on openscreen export. The audio is the MP4 export's own mix, taken out of a render, so offset, trims, gain, fades and speed regions follow the video export's rules.
1 parent d8e573d commit 7921b6d

16 files changed

Lines changed: 424 additions & 49 deletions

‎electron/cli/args.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,29 @@ describe("parseCliArgs", () => {
8484
});
8585
});
8686

87+
it("exports the audio alone to a .wav or .m4a, the whole programme or one track", () => {
88+
expect(
89+
parse(["export", "a.openscreen", "-o", "voice.wav", "--track", "audio_1"]),
90+
).toMatchObject({
91+
kind: "export",
92+
outPath: inCwd("voice.wav"),
93+
format: "wav",
94+
audioTrackId: "audio_1",
95+
});
96+
expect(parse(["export", "a.openscreen", "--format", "m4a"])).toMatchObject({ format: "m4a" });
97+
// One track only makes an audio file, and an audio file takes none of the picture's options.
98+
for (const args of [
99+
["--track", "audio_1"],
100+
["-o", "out.mp4", "--track", "audio_1"],
101+
["-o", "out.wav", "--audio", "v.mp3"],
102+
["-o", "out.wav", "--quality", "good"],
103+
["-o", "out.m4a", "--gif-size", "small"],
104+
["-o", "out.flac"],
105+
]) {
106+
expect(parse(["export", "a.openscreen", ...args])).toMatchObject({ kind: "error" });
107+
}
108+
});
109+
87110
it("defaults audio mode to mix and rejects --audio with gif", () => {
88111
expect(parse(["export", "a.openscreen", "--audio", "v.mp3"])).toMatchObject({
89112
audioMode: "mix",

‎electron/cli/args.ts‎

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ import {
66
CAPTION_WORDS_PER_LINE_MAX,
77
CAPTION_WORDS_PER_LINE_MIN,
88
} from "../../src/lib/ai-edition/captions/wordsPerLine";
9-
import type { CliExportRequest, CliRecordRequest, CliRequest } from "../../src/lib/cliContracts";
9+
import {
10+
type CliExportRequest,
11+
type CliRecordRequest,
12+
type CliRequest,
13+
isAudioExportFormat,
14+
} from "../../src/lib/cliContracts";
1015
import {
1116
GIF_SIZE_PRESETS,
1217
type GifSizePreset,
@@ -80,7 +85,7 @@ const SUBCOMMANDS = new Set([
8085
export const CLI_USAGE = `OpenScreen CLI
8186
8287
Usage:
83-
openscreen export <project.openscreen> [options] Render a project to MP4/GIF
88+
openscreen export <project.openscreen> [options] Render a project to MP4/GIF, or its audio to WAV/M4A
8489
openscreen record [options] Record the screen headlessly
8590
openscreen sources [--json] [-o <file>] List displays, windows, microphones and cameras
8691
openscreen pack <project.openscreen> --out <dir> Copy project + media into one portable folder
@@ -91,8 +96,11 @@ Usage:
9196
openscreen help Show this help
9297
9398
Export options:
94-
-o, --out <path> Output file (.mp4 or .gif). Default: next to the project file
95-
--format <mp4|gif> Override the format stored in the project
99+
-o, --out <path> Output file (.mp4, .gif, .wav or .m4a). Default: next to the project file
100+
--format <mp4|gif|wav|m4a>
101+
Override the format stored in the project. wav and m4a write
102+
the project's audio only, as the MP4 export mixes it
103+
--track <audioId> WAV/M4A only: write this imported audio track alone
96104
--quality <medium|good|source>
97105
MP4 quality (default: from project)
98106
--fps <24|30|60> MP4 frame rate (default 60)
@@ -221,8 +229,8 @@ function parseExport(args: string[], cwd: string): CliCommand {
221229
}
222230
case "--format": {
223231
const [value, next] = takeValue(args, i, arg);
224-
if (value !== "mp4" && value !== "gif") {
225-
throw new Error(`--format must be mp4 or gif, got "${value}"`);
232+
if (value !== "mp4" && value !== "gif" && !isAudioExportFormat(value)) {
233+
throw new Error(`--format must be mp4, gif, wav or m4a, got "${value}"`);
226234
}
227235
request.format = value;
228236
i = next;
@@ -271,6 +279,12 @@ function parseExport(args: string[], cwd: string): CliCommand {
271279
case "--no-gif-loop":
272280
request.gifLoop = arg === "--gif-loop";
273281
break;
282+
case "--track": {
283+
const [value, next] = takeValue(args, i, arg);
284+
request.audioTrackId = value;
285+
i = next;
286+
break;
287+
}
274288
case "--auto-zoom":
275289
request.autoZoom = true;
276290
break;
@@ -314,21 +328,24 @@ function parseExport(args: string[], cwd: string): CliCommand {
314328
if (!request.projectPath) throw new Error("export requires a <project.openscreen> path");
315329
if (request.outPath) {
316330
const ext = path.extname(request.outPath).toLowerCase();
317-
if (ext !== ".mp4" && ext !== ".gif") {
318-
throw new Error(`--out must end in .mp4 or .gif, got "${request.outPath}"`);
331+
const extFormat = ext.slice(1);
332+
if (extFormat !== "mp4" && extFormat !== "gif" && !isAudioExportFormat(extFormat)) {
333+
throw new Error(`--out must end in .mp4, .gif, .wav or .m4a, got "${request.outPath}"`);
319334
}
320-
const extFormat = ext === ".gif" ? "gif" : "mp4";
321335
if (request.format && request.format !== extFormat) {
322336
throw new Error(`--format ${request.format} conflicts with --out extension ${ext}`);
323337
}
324338
request.format = extFormat;
325339
}
326340
// After --out resolves the format, so `--audio … -o out.gif` is caught too.
327-
if (request.audioPath && request.format === "gif") {
341+
if (request.audioPath && (request.format === "gif" || isAudioExportFormat(request.format))) {
328342
throw new Error("--audio is only supported for MP4 exports");
329343
}
344+
if (request.audioTrackId && !isAudioExportFormat(request.format)) {
345+
throw new Error("--track is only supported for audio exports (.wav or .m4a)");
346+
}
330347
// The MP4 options: a GIF is sized from the source and has its own frame rate.
331-
if (request.format === "gif" && request.quality) {
348+
if (request.format !== "mp4" && request.format !== null && request.quality) {
332349
throw new Error("--quality is only supported for MP4 exports");
333350
}
334351
if (request.format === "gif" && args.includes("--fps")) {
@@ -338,7 +355,7 @@ function parseExport(args: string[], cwd: string): CliCommand {
338355
const gifFlag = ["--gif-fps", "--gif-size", "--gif-loop", "--no-gif-loop"].find((flag) =>
339356
args.includes(flag),
340357
);
341-
if (request.format === "mp4" && gifFlag) {
358+
if (request.format !== "gif" && request.format !== null && gifFlag) {
342359
throw new Error(`${gifFlag} is only supported for GIF exports`);
343360
}
344361
return request;

‎electron/cli/runnerHost.test.ts‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { EventEmitter } from "node:events";
2+
import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
3+
import os from "node:os";
4+
import path from "node:path";
25
import { describe, expect, it } from "vitest";
3-
import type { CliRequest } from "../../src/lib/cliContracts";
6+
import { audioRenderPath, type CliRequest } from "../../src/lib/cliContracts";
47
import { createRunnerHost, type RunnerIpc } from "./runnerHost";
58

69
type Listener = (event: { sender: { id: number } }, ...args: unknown[]) => unknown;
@@ -79,6 +82,38 @@ describe("createRunnerHost", () => {
7982
await expect(first.done).resolves.toEqual({ success: false, error: "no display" });
8083
});
8184

85+
it("removes the video an audio export rendered its audio from, and keeps a video export's", async () => {
86+
const dir = await mkdtemp(path.join(os.tmpdir(), "runner-host-"));
87+
const outPath = path.join(dir, "voice.wav");
88+
await writeFile(audioRenderPath(outPath), "mp4");
89+
await writeFile(path.join(dir, "demo.mp4"), "mp4");
90+
const { ipc, host } = setup();
91+
const exportOf = (format: "wav" | "mp4", out: string): CliRequest => ({
92+
kind: "export",
93+
projectPath: path.join(dir, "p.openscreen"),
94+
outPath: out,
95+
format,
96+
quality: null,
97+
mp4FrameRate: 60,
98+
gifFrameRate: null,
99+
gifSizePreset: null,
100+
gifLoop: null,
101+
autoZoom: false,
102+
audioPath: null,
103+
audioMode: "mix",
104+
audioOffsetSec: 0,
105+
});
106+
const audio = host.start(exportOf("wav", outPath));
107+
const video = host.start(exportOf("mp4", path.join(dir, "demo.mp4.render.mp4")));
108+
await writeFile(path.join(dir, "demo.mp4.render.mp4.render.mp4"), "mp4");
109+
ipc.call(1, "cli-done", { success: true, outputPath: outPath });
110+
ipc.call(2, "cli-done", { success: true });
111+
await audio.done;
112+
await video.done;
113+
expect((await readdir(dir)).sort()).toEqual(["demo.mp4", "demo.mp4.render.mp4.render.mp4"]);
114+
await rm(dir, { recursive: true, force: true });
115+
});
116+
82117
it("routes logs and progress to the run they came from", () => {
83118
const { ipc, host } = setup();
84119
const logs: string[] = [];

‎electron/cli/runnerHost.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,15 @@
55
// No `electron` import: the IPC surface and the window factory are handed in,
66
// so the routing stays unit-testable.
77

8+
import { rm } from "node:fs/promises";
89
import type { IpcMain } from "electron";
9-
import type { CliDoneResult, CliProgressEvent, CliRequest } from "../../src/lib/cliContracts";
10+
import {
11+
audioRenderPath,
12+
type CliDoneResult,
13+
type CliProgressEvent,
14+
type CliRequest,
15+
isAudioExportFormat,
16+
} from "../../src/lib/cliContracts";
1017

1118
/** The slice of a BrowserWindow this needs. */
1219
export interface RunnerWindow {
@@ -98,9 +105,19 @@ export function createRunnerHost<W extends RunnerWindow>(
98105
const id = window.webContents.id;
99106
// Registered in the same tick the load starts in: the renderer cannot ask
100107
// for its request before this line has run.
101-
const done = new Promise<CliDoneResult>((resolve) => {
108+
const reported = new Promise<CliDoneResult>((resolve) => {
102109
runs.set(id, { request, handlers, resolve });
103110
});
111+
// An audio export renders an MP4 beside its output that the runner cannot delete.
112+
const done =
113+
request.kind === "export" && isAudioExportFormat(request.format)
114+
? reported.then(async (result) => {
115+
const outPath = request.outPath ?? result.outputPath;
116+
if (outPath)
117+
await rm(audioRenderPath(outPath), { force: true }).catch(() => undefined);
118+
return result;
119+
})
120+
: reported;
104121
// A runner that never reports would otherwise hold its caller forever.
105122
const fail = (error: string) => {
106123
const run = runs.get(id);

‎electron/ipc/handlers.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4229,7 +4229,7 @@ export function registerIpcHandlers(
42294229
return { success: false, message: "Invalid path" };
42304230
}
42314231
const lower = filePath.toLowerCase();
4232-
if (!lower.endsWith(".mp4") && !lower.endsWith(".gif")) {
4232+
if (![".mp4", ".gif", ".wav", ".m4a"].some((extension) => lower.endsWith(extension))) {
42334233
return { success: false, message: "Invalid file type" };
42344234
}
42354235

‎electron/mcp/media-tools.test.ts‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,42 @@ describe("exportVideo", () => {
706706
});
707707
});
708708

709+
describe("exportAudio", () => {
710+
it("renders the project's audio, or one track of it, to the path asked for", async () => {
711+
const { projectId } = await recordTake();
712+
const outPath = path.join(dir, "voice.m4a");
713+
runners.results.export = { success: true, outputPath: outPath, format: "m4a" };
714+
const exported = await call("exportAudio", { projectId, path: outPath, audioId: "audio_1" });
715+
expect(exported.isError).toBeFalsy();
716+
expect(JSON.parse(textOf(exported))).toEqual({ path: outPath, format: "m4a" });
717+
expect(runners.started.at(-1)).toMatchObject({
718+
kind: "export",
719+
projectPath: documents.filePathFor(projectId),
720+
projectId,
721+
outPath,
722+
format: "m4a",
723+
mp4FrameRate: 60,
724+
audioTrackId: "audio_1",
725+
});
726+
await call("exportAudio", { projectId, path: path.join(dir, "mix.wav") });
727+
expect(runners.started.at(-1)).toMatchObject({ format: "wav", audioTrackId: null });
728+
expect(runners.open).toBe(0);
729+
});
730+
731+
it("refuses a path that is not audio or an unknown project, and reports a failed render", async () => {
732+
const { projectId } = await recordTake();
733+
const exports = () => runners.started.filter((request) => request.kind === "export").length;
734+
const before = exports();
735+
expect((await call("exportAudio", { projectId, path: "out.mp3" })).isError).toBe(true);
736+
const unknown = await call("exportAudio", { projectId: "proj_x", path: "out.wav" });
737+
expect(textOf(unknown)).toContain('No project has the id "proj_x"');
738+
expect(exports()).toBe(before);
739+
runners.results.export = { success: false, error: "no audio track" };
740+
const failed = await call("exportAudio", { projectId, path: "out.wav" });
741+
expect(textOf(failed)).toContain("no audio track");
742+
});
743+
});
744+
709745
describe("getFrames on a project", () => {
710746
it("reads the last export at the ruler seconds the document tools use", async () => {
711747
const { projectId } = await recordTake();
@@ -976,7 +1012,7 @@ describe("the media tools in the app", () => {
9761012
allowed = false;
9771013
const app = await connectApp();
9781014
expect(app.getInstructions()).toContain(
979-
"startRecording, exportVideo and createProject are refused too while 'MCP edits'",
1015+
"startRecording, exportVideo, exportAudio and createProject are refused too while 'MCP edits'",
9801016
);
9811017
const started = await callOn(app, "startRecording", {});
9821018
expect(started.isError).toBe(true);

0 commit comments

Comments
 (0)