Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions electron/cli/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,29 @@ describe("parseCliArgs", () => {
});
});

it("exports the audio alone to a .wav or .m4a, the whole programme or one track", () => {
expect(
parse(["export", "a.openscreen", "-o", "voice.wav", "--track", "audio_1"]),
).toMatchObject({
kind: "export",
outPath: inCwd("voice.wav"),
format: "wav",
audioTrackId: "audio_1",
});
expect(parse(["export", "a.openscreen", "--format", "m4a"])).toMatchObject({ format: "m4a" });
// One track only makes an audio file, and an audio file takes none of the picture's options.
for (const args of [
["--track", "audio_1"],
["-o", "out.mp4", "--track", "audio_1"],
["-o", "out.wav", "--audio", "v.mp3"],
["-o", "out.wav", "--quality", "good"],
["-o", "out.m4a", "--gif-size", "small"],
["-o", "out.flac"],
]) {
expect(parse(["export", "a.openscreen", ...args])).toMatchObject({ kind: "error" });
}
});

it("defaults audio mode to mix and rejects --audio with gif", () => {
expect(parse(["export", "a.openscreen", "--audio", "v.mp3"])).toMatchObject({
audioMode: "mix",
Expand Down
41 changes: 29 additions & 12 deletions electron/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import {
CAPTION_WORDS_PER_LINE_MAX,
CAPTION_WORDS_PER_LINE_MIN,
} from "../../src/lib/ai-edition/captions/wordsPerLine";
import type { CliExportRequest, CliRecordRequest, CliRequest } from "../../src/lib/cliContracts";
import {
type CliExportRequest,
type CliRecordRequest,
type CliRequest,
isAudioExportFormat,
} from "../../src/lib/cliContracts";
import {
GIF_SIZE_PRESETS,
type GifSizePreset,
Expand Down Expand Up @@ -80,7 +85,7 @@ const SUBCOMMANDS = new Set([
export const CLI_USAGE = `OpenScreen CLI

Usage:
openscreen export <project.openscreen> [options] Render a project to MP4/GIF
openscreen export <project.openscreen> [options] Render a project to MP4/GIF, or its audio to WAV/M4A
openscreen record [options] Record the screen headlessly
openscreen sources [--json] [-o <file>] List displays, windows, microphones and cameras
openscreen pack <project.openscreen> --out <dir> Copy project + media into one portable folder
Expand All @@ -91,8 +96,11 @@ Usage:
openscreen help Show this help

Export options:
-o, --out <path> Output file (.mp4 or .gif). Default: next to the project file
--format <mp4|gif> Override the format stored in the project
-o, --out <path> Output file (.mp4, .gif, .wav or .m4a). Default: next to the project file
--format <mp4|gif|wav|m4a>
Override the format stored in the project. wav and m4a write
the project's audio only, as the MP4 export mixes it
--track <audioId> WAV/M4A only: write this imported audio track alone
--quality <medium|good|source>
MP4 quality (default: from project)
--fps <24|30|60> MP4 frame rate (default 60)
Expand Down Expand Up @@ -221,8 +229,8 @@ function parseExport(args: string[], cwd: string): CliCommand {
}
case "--format": {
const [value, next] = takeValue(args, i, arg);
if (value !== "mp4" && value !== "gif") {
throw new Error(`--format must be mp4 or gif, got "${value}"`);
if (value !== "mp4" && value !== "gif" && !isAudioExportFormat(value)) {
throw new Error(`--format must be mp4, gif, wav or m4a, got "${value}"`);
}
request.format = value;
i = next;
Expand Down Expand Up @@ -271,6 +279,12 @@ function parseExport(args: string[], cwd: string): CliCommand {
case "--no-gif-loop":
request.gifLoop = arg === "--gif-loop";
break;
case "--track": {
const [value, next] = takeValue(args, i, arg);
request.audioTrackId = value;
i = next;
break;
}
case "--auto-zoom":
request.autoZoom = true;
break;
Expand Down Expand Up @@ -314,21 +328,24 @@ function parseExport(args: string[], cwd: string): CliCommand {
if (!request.projectPath) throw new Error("export requires a <project.openscreen> path");
if (request.outPath) {
const ext = path.extname(request.outPath).toLowerCase();
if (ext !== ".mp4" && ext !== ".gif") {
throw new Error(`--out must end in .mp4 or .gif, got "${request.outPath}"`);
const extFormat = ext.slice(1);
if (extFormat !== "mp4" && extFormat !== "gif" && !isAudioExportFormat(extFormat)) {
throw new Error(`--out must end in .mp4, .gif, .wav or .m4a, got "${request.outPath}"`);
}
const extFormat = ext === ".gif" ? "gif" : "mp4";
if (request.format && request.format !== extFormat) {
throw new Error(`--format ${request.format} conflicts with --out extension ${ext}`);
}
request.format = extFormat;
}
// After --out resolves the format, so `--audio … -o out.gif` is caught too.
if (request.audioPath && request.format === "gif") {
if (request.audioPath && (request.format === "gif" || isAudioExportFormat(request.format))) {
throw new Error("--audio is only supported for MP4 exports");
}
if (request.audioTrackId && !isAudioExportFormat(request.format)) {
throw new Error("--track is only supported for audio exports (.wav or .m4a)");
}
// The MP4 options: a GIF is sized from the source and has its own frame rate.
if (request.format === "gif" && request.quality) {
if (request.format !== "mp4" && request.format !== null && request.quality) {
throw new Error("--quality is only supported for MP4 exports");
}
if (request.format === "gif" && args.includes("--fps")) {
Expand All @@ -338,7 +355,7 @@ function parseExport(args: string[], cwd: string): CliCommand {
const gifFlag = ["--gif-fps", "--gif-size", "--gif-loop", "--no-gif-loop"].find((flag) =>
args.includes(flag),
);
if (request.format === "mp4" && gifFlag) {
if (request.format !== "gif" && request.format !== null && gifFlag) {
throw new Error(`${gifFlag} is only supported for GIF exports`);
}
return request;
Expand Down
42 changes: 41 additions & 1 deletion electron/cli/runnerHost.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { EventEmitter } from "node:events";
import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { CliRequest } from "../../src/lib/cliContracts";
import { audioRenderPath, type CliRequest } from "../../src/lib/cliContracts";
import { createRunnerHost, type RunnerIpc } from "./runnerHost";

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

it("removes the video an audio export rendered its audio from, and keeps a video export's", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "runner-host-"));
const outPath = path.join(dir, "voice.wav");
await writeFile(audioRenderPath(outPath), "mp4");
await writeFile(path.join(dir, "demo.mp4"), "mp4");
const { ipc, host } = setup();
const exportOf = (format: "wav" | "mp4", out: string | null): CliRequest => ({
kind: "export",
projectPath: path.join(dir, "p.openscreen"),
outPath: out,
format,
quality: null,
mp4FrameRate: 60,
gifFrameRate: null,
gifSizePreset: null,
gifLoop: null,
autoZoom: false,
audioPath: null,
audioMode: "mix",
audioOffsetSec: 0,
});
const audio = host.start(exportOf("wav", outPath));
const video = host.start(exportOf("mp4", path.join(dir, "demo.mp4.render.mp4")));
await writeFile(path.join(dir, "demo.mp4.render.mp4.render.mp4"), "mp4");
// Without --out, a failed run reports no outputPath: the render beside p.wav still goes.
const failed = host.start(exportOf("wav", null));
await writeFile(audioRenderPath(path.join(dir, "p.wav")), "mp4");
ipc.call(1, "cli-done", { success: true, outputPath: outPath });
ipc.call(2, "cli-done", { success: true });
ipc.call(3, "cli-done", { success: false, error: "render failed" });
await audio.done;
await video.done;
await failed.done;
expect((await readdir(dir)).sort()).toEqual(["demo.mp4", "demo.mp4.render.mp4.render.mp4"]);
await rm(dir, { recursive: true, force: true });
});

it("routes logs and progress to the run they came from", () => {
const { ipc, host } = setup();
const logs: string[] = [];
Expand Down
25 changes: 23 additions & 2 deletions electron/cli/runnerHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,16 @@
// No `electron` import: the IPC surface and the window factory are handed in,
// so the routing stays unit-testable.

import { rm } from "node:fs/promises";
import type { IpcMain } from "electron";
import type { CliDoneResult, CliProgressEvent, CliRequest } from "../../src/lib/cliContracts";
import {
audioRenderPath,
type CliDoneResult,
type CliProgressEvent,
type CliRequest,
defaultExportPath,
isAudioExportFormat,
} from "../../src/lib/cliContracts";

/** The slice of a BrowserWindow this needs. */
export interface RunnerWindow {
Expand Down Expand Up @@ -98,9 +106,22 @@ export function createRunnerHost<W extends RunnerWindow>(
const id = window.webContents.id;
// Registered in the same tick the load starts in: the renderer cannot ask
// for its request before this line has run.
const done = new Promise<CliDoneResult>((resolve) => {
const reported = new Promise<CliDoneResult>((resolve) => {
runs.set(id, { request, handlers, resolve });
});
// An audio export renders an MP4 beside its output that the runner cannot delete.
const audioFormat =
request.kind === "export" && isAudioExportFormat(request.format) ? request.format : null;
const done =
request.kind === "export" && audioFormat
? reported.then(async (result) => {
// Derived as the runner derives it: a failed run reports no outputPath.
const outPath =
request.outPath ?? defaultExportPath(request.projectPath, audioFormat);
await rm(audioRenderPath(outPath), { force: true }).catch(() => undefined);
return result;
})
: reported;
// A runner that never reports would otherwise hold its caller forever.
const fail = (error: string) => {
const run = runs.get(id);
Expand Down
2 changes: 1 addition & 1 deletion electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4229,7 +4229,7 @@ export function registerIpcHandlers(
return { success: false, message: "Invalid path" };
}
const lower = filePath.toLowerCase();
if (!lower.endsWith(".mp4") && !lower.endsWith(".gif")) {
if (![".mp4", ".gif", ".wav", ".m4a"].some((extension) => lower.endsWith(extension))) {
return { success: false, message: "Invalid file type" };
}

Expand Down
38 changes: 37 additions & 1 deletion electron/mcp/media-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,42 @@ describe("exportVideo", () => {
});
});

describe("exportAudio", () => {
it("renders the project's audio, or one track of it, to the path asked for", async () => {
const { projectId } = await recordTake();
const outPath = path.join(dir, "voice.m4a");
runners.results.export = { success: true, outputPath: outPath, format: "m4a" };
const exported = await call("exportAudio", { projectId, path: outPath, audioId: "audio_1" });
expect(exported.isError).toBeFalsy();
expect(JSON.parse(textOf(exported))).toEqual({ path: outPath, format: "m4a" });
expect(runners.started.at(-1)).toMatchObject({
kind: "export",
projectPath: documents.filePathFor(projectId),
projectId,
outPath,
format: "m4a",
mp4FrameRate: 60,
audioTrackId: "audio_1",
});
await call("exportAudio", { projectId, path: path.join(dir, "mix.wav") });
expect(runners.started.at(-1)).toMatchObject({ format: "wav", audioTrackId: null });
expect(runners.open).toBe(0);
});

it("refuses a path that is not audio or an unknown project, and reports a failed render", async () => {
const { projectId } = await recordTake();
const exports = () => runners.started.filter((request) => request.kind === "export").length;
const before = exports();
expect((await call("exportAudio", { projectId, path: "out.mp3" })).isError).toBe(true);
const unknown = await call("exportAudio", { projectId: "proj_x", path: "out.wav" });
expect(textOf(unknown)).toContain('No project has the id "proj_x"');
expect(exports()).toBe(before);
runners.results.export = { success: false, error: "no audio track" };
const failed = await call("exportAudio", { projectId, path: "out.wav" });
expect(textOf(failed)).toContain("no audio track");
});
});

describe("getFrames on a project", () => {
it("reads the last export at the ruler seconds the document tools use", async () => {
const { projectId } = await recordTake();
Expand Down Expand Up @@ -976,7 +1012,7 @@ describe("the media tools in the app", () => {
allowed = false;
const app = await connectApp();
expect(app.getInstructions()).toContain(
"startRecording, exportVideo and createProject are refused too while 'MCP edits'",
"startRecording, exportVideo, exportAudio and createProject are refused too while 'MCP edits'",
);
const started = await callOn(app, "startRecording", {});
expect(started.isError).toBe(true);
Expand Down
Loading
Loading