From 51fd3803c7915e39195603de4ab23fb77d086c4a Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 24 Oct 2026 17:09:41 +0200 Subject: [PATCH 1/2] 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. --- electron/cli/args.test.ts | 23 ++++ electron/cli/args.ts | 41 +++++-- electron/cli/runnerHost.test.ts | 37 +++++- electron/cli/runnerHost.ts | 21 +++- electron/ipc/handlers.ts | 2 +- electron/mcp/media-tools.test.ts | 38 +++++- electron/mcp/media-tools.ts | 113 +++++++++++++++--- electron/mcp/openscreen-mcp-server.test.ts | 5 +- electron/mcp/openscreen-mcp-server.ts | 2 +- src/cli/CliExportRunner.tsx | 57 +++++++-- src/lib/cliContracts.ts | 20 +++- src/lib/exporter/audioExport.test.ts | 41 +++++++ src/lib/exporter/audioExport.ts | 55 +++++++++ .../architecture/mcp-server.md | 5 +- website/docs/ai-editing.md | 2 +- website/docs/cli.md | 11 +- 16 files changed, 424 insertions(+), 49 deletions(-) create mode 100644 src/lib/exporter/audioExport.test.ts create mode 100644 src/lib/exporter/audioExport.ts diff --git a/electron/cli/args.test.ts b/electron/cli/args.test.ts index d657f7a98d..5d52f2ee9a 100644 --- a/electron/cli/args.test.ts +++ b/electron/cli/args.test.ts @@ -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", diff --git a/electron/cli/args.ts b/electron/cli/args.ts index 6c13230108..b2b50bd7b2 100644 --- a/electron/cli/args.ts +++ b/electron/cli/args.ts @@ -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, @@ -80,7 +85,7 @@ const SUBCOMMANDS = new Set([ export const CLI_USAGE = `OpenScreen CLI Usage: - openscreen export [options] Render a project to MP4/GIF + openscreen export [options] Render a project to MP4/GIF, or its audio to WAV/M4A openscreen record [options] Record the screen headlessly openscreen sources [--json] [-o ] List displays, windows, microphones and cameras openscreen pack --out Copy project + media into one portable folder @@ -91,8 +96,11 @@ Usage: openscreen help Show this help Export options: - -o, --out Output file (.mp4 or .gif). Default: next to the project file - --format Override the format stored in the project + -o, --out Output file (.mp4, .gif, .wav or .m4a). Default: next to the project file + --format + Override the format stored in the project. wav and m4a write + the project's audio only, as the MP4 export mixes it + --track WAV/M4A only: write this imported audio track alone --quality MP4 quality (default: from project) --fps <24|30|60> MP4 frame rate (default 60) @@ -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; @@ -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; @@ -314,21 +328,24 @@ function parseExport(args: string[], cwd: string): CliCommand { if (!request.projectPath) throw new Error("export requires a 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")) { @@ -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; diff --git a/electron/cli/runnerHost.test.ts b/electron/cli/runnerHost.test.ts index 3f0abde775..80b760cf0f 100644 --- a/electron/cli/runnerHost.test.ts +++ b/electron/cli/runnerHost.test.ts @@ -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; @@ -79,6 +82,38 @@ 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): 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"); + ipc.call(1, "cli-done", { success: true, outputPath: outPath }); + ipc.call(2, "cli-done", { success: true }); + await audio.done; + await video.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[] = []; diff --git a/electron/cli/runnerHost.ts b/electron/cli/runnerHost.ts index 7475aa2dc8..7dd6e3a900 100644 --- a/electron/cli/runnerHost.ts +++ b/electron/cli/runnerHost.ts @@ -5,8 +5,15 @@ // 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, + isAudioExportFormat, +} from "../../src/lib/cliContracts"; /** The slice of a BrowserWindow this needs. */ export interface RunnerWindow { @@ -98,9 +105,19 @@ export function createRunnerHost( 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((resolve) => { + const reported = new Promise((resolve) => { runs.set(id, { request, handlers, resolve }); }); + // An audio export renders an MP4 beside its output that the runner cannot delete. + const done = + request.kind === "export" && isAudioExportFormat(request.format) + ? reported.then(async (result) => { + const outPath = request.outPath ?? result.outputPath; + if (outPath) + 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); diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 8ac4658c79..7e24aea80d 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -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" }; } diff --git a/electron/mcp/media-tools.test.ts b/electron/mcp/media-tools.test.ts index aaaf3cb3ac..477df437ef 100644 --- a/electron/mcp/media-tools.test.ts +++ b/electron/mcp/media-tools.test.ts @@ -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(); @@ -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); diff --git a/electron/mcp/media-tools.ts b/electron/mcp/media-tools.ts index c112d2bda2..7bfaf9ae37 100644 --- a/electron/mcp/media-tools.ts +++ b/electron/mcp/media-tools.ts @@ -14,7 +14,12 @@ import path from "node:path"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { + CallToolResult, + ServerNotification, + ServerRequest, +} from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import type { CursorTelemetryPoint } from "../../src/components/video-editor/types"; import { @@ -26,7 +31,12 @@ import { appendAutoZoomSuggestions, collectAutoZoomSuggestionsForDocument, } from "../../src/lib/ai-edition/timeline/apply-auto-zooms"; -import type { CliDoneResult, CliRecordRequest, CliRequest } from "../../src/lib/cliContracts"; +import { + type CliDoneResult, + type CliRecordRequest, + type CliRequest, + isAudioExportFormat, +} from "../../src/lib/cliContracts"; import { HIDDEN_WINDOWS_NOTE } from "../../src/lib/cliSourcePick"; import { GIF_SIZE_PRESETS, @@ -263,6 +273,12 @@ const EXPORT_PATH_DESCRIPTIONS: Record = { "Where to write the video: an absolute path, or one relative to this server's working directory, ending in .mp4 or .gif.", }; +const AUDIO_PATH_DESCRIPTIONS: Record = { + http: "Where to write the audio: an absolute path ending in .wav or .m4a. This server runs inside the app, so it has no working directory of yours to resolve a relative one against.", + stdio: + "Where to write the audio: an absolute path, or one relative to this server's working directory, ending in .wav or .m4a.", +}; + const GATED_MESSAGE = `Nothing was recorded or exported: OpenScreen lets MCP clients record and export only while the user allows it. Ask the user to turn on ${MCP_EDITS_SETTING}.`; /** A path a client passed, resolved; `null` for a relative one the in-app server cannot place. */ @@ -335,6 +351,22 @@ export async function pointerTrackEdit( type Run = ReturnType; +/** An export's progress, sent to the client when it asked for it. */ +function progressNotifier( + extra: RequestHandlerExtra, +): RunnerHandlers["onProgress"] { + const progressToken = extra._meta?.progressToken; + return (progress) => { + if (progressToken === undefined) return; + void extra + .sendNotification({ + method: "notifications/progress", + params: { progressToken, progress: progress.percentage, total: 100 }, + }) + .catch(() => undefined); + }; +} + /** A runner's result, its hidden window closed: the server outlives every runner. */ async function finish(run: Run): Promise { try { @@ -816,7 +848,6 @@ export function createMediaTools(deps: MediaToolDeps): MediaTools { true, ); } - const progressToken = extra._meta?.progressToken; exporting = true; try { // The project as the export reads it, and when: getFrames maps ruler seconds through @@ -847,17 +878,7 @@ export function createMediaTools(deps: MediaToolDeps): MediaTools { audioMode: "mix", audioOffsetSec: 0, }, - { - onProgress: (progress) => { - if (progressToken === undefined) return; - void extra - .sendNotification({ - method: "notifications/progress", - params: { progressToken, progress: progress.percentage, total: 100 }, - }) - .catch(() => undefined); - }, - }, + { onProgress: progressNotifier(extra) }, ); const result = await finish(run); if (!result.success) { @@ -892,6 +913,70 @@ export function createMediaTools(deps: MediaToolDeps): MediaTools { }, ); + server.registerTool( + "exportAudio", + { + description: + "Write a project's audio to a WAV or an M4A, exactly as exportVideo's MP4 mixes it: the recording's sound, the imported tracks with their offset, trims, gain and fades, through the same cuts and speed regions. With audioId (addAudio's, or an audioTracks id), only that track, alone, placed where it plays in the export: the file lasts as long as the video, silent around the track. It renders the project as an export does, so it takes about as long as exportVideo.", + inputSchema: z.object({ + projectId: z.string(), + path: z.string().describe(AUDIO_PATH_DESCRIPTIONS[transport]), + audioId: z + .string() + .optional() + .describe("Export this imported track alone. Default: the whole programme audio."), + }), + annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, + }, + async (args, extra) => { + if (!deps.editsAllowed()) return text(GATED_MESSAGE, true); + if (take) return text("A recording is running. Stop it before exporting.", true); + if (exporting) + return text("An export is already running. Wait for it, then try again.", true); + const outPath = clientPath(transport, args.path); + if (!outPath) return text(`The path must be absolute, got "${args.path}".`, true); + const format = path.extname(outPath).toLowerCase().slice(1); + if (!isAudioExportFormat(format)) { + return text(`The path must end in .wav or .m4a, got "${args.path}".`, true); + } + exporting = true; + try { + if (!(await deps.documents.getProject(args.projectId).catch(() => null))) { + return text(`No project has the id "${args.projectId}".`, true); + } + const result = await finish( + deps.runners.start( + { + kind: "export", + projectPath: deps.documents.filePathFor(args.projectId), + projectId: args.projectId, + outPath, + format, + quality: null, + // The frame grid exportVideo lays the audio out on by default. + mp4FrameRate: EXPORT_FPS, + gifFrameRate: null, + gifSizePreset: null, + gifLoop: null, + autoZoom: false, + audioPath: null, + audioMode: "mix", + audioOffsetSec: 0, + audioTrackId: args.audioId ?? null, + }, + { onProgress: progressNotifier(extra) }, + ), + ); + if (!result.success) { + return text(`The export failed: ${result.error ?? "unknown error"}`, true); + } + return text(JSON.stringify({ path: result.outputPath, format: result.format })); + } finally { + exporting = false; + } + }, + ); + server.registerTool( "getFrames", { diff --git a/electron/mcp/openscreen-mcp-server.test.ts b/electron/mcp/openscreen-mcp-server.test.ts index 85e0fbf2d5..bcdde4b26d 100644 --- a/electron/mcp/openscreen-mcp-server.test.ts +++ b/electron/mcp/openscreen-mcp-server.test.ts @@ -209,6 +209,7 @@ describe("the MCP tool surface", () => { "stopRecording", "setPointerTrack", "exportVideo", + "exportAudio", "getFrames", "createProject", ]); @@ -259,7 +260,9 @@ describe("the MCP tool surface", () => { "Without a projectId they act on the project open in the editor", ); expect(instructions).toContain("startRecording hands over to OpenScreen's recording bar"); - expect(instructions).toContain("startRecording, exportVideo and createProject are refused too"); + expect(instructions).toContain( + "startRecording, exportVideo, exportAudio and createProject are refused too", + ); expect(instructions).toContain("all of them are lost when OpenScreen quits."); expect(instructions).toContain("turn on 'MCP edits' under MCP server in AI settings"); expect(instructions).not.toContain("Agent edits"); diff --git a/electron/mcp/openscreen-mcp-server.ts b/electron/mcp/openscreen-mcp-server.ts index 257bd70897..c73378b6a5 100644 --- a/electron/mcp/openscreen-mcp-server.ts +++ b/electron/mcp/openscreen-mcp-server.ts @@ -295,7 +295,7 @@ export function mcpInstructions(options: { CHECKPOINT_LIFETIMES[transport], ...(media && !editsAllowed ? [ - `startRecording, exportVideo and createProject are refused too while ${MCP_EDITS_SETTING} is off.`, + `startRecording, exportVideo, exportAudio and createProject are refused too while ${MCP_EDITS_SETTING} is off.`, ] : []), ...(options.transcribe ? [TRANSCRIBE_NOTE] : []), diff --git a/src/cli/CliExportRunner.tsx b/src/cli/CliExportRunner.tsx index 97d3e6efae..e0e28dc7d3 100644 --- a/src/cli/CliExportRunner.tsx +++ b/src/cli/CliExportRunner.tsx @@ -18,8 +18,14 @@ import { appendAutoZoomSuggestions, collectAutoZoomSuggestionsForDocument, } from "@/lib/ai-edition/timeline/apply-auto-zooms"; -import type { CliDoneResult, CliExportRequest } from "@/lib/cliContracts"; +import { + audioRenderPath, + type CliDoneResult, + type CliExportRequest, + isAudioExportFormat, +} from "@/lib/cliContracts"; import { GIF_SIZE_PRESETS, type GifSizePreset } from "@/lib/exporter"; +import { extractAudio, isolateAudioTrack } from "@/lib/exporter/audioExport"; import { calculateMp4ExportSettings } from "@/lib/exporter/mp4ExportSettings"; import { outputFrameCount } from "@/lib/exporter/outputFrameCount"; import { mixVoiceoverIntoVideo } from "@/lib/exporter/voiceoverMix"; @@ -67,11 +73,15 @@ async function runExport(request: CliExportRequest): Promise { let axcutDocument = project.document; const format = request.format ?? editor.exportFormat; - if (request.audioPath && format === "gif") { + const audioFormat = isAudioExportFormat(format) ? format : null; + if (request.audioPath && format !== "mp4") { throw new Error( - "--audio is only supported for MP4 exports (this project's stored format is gif; pass --format mp4)", + `--audio is only supported for MP4 exports (this export's format is ${format}; pass --format mp4)`, ); } + if (request.audioTrackId && !audioFormat) { + throw new Error("--track is only supported for audio exports (.wav or .m4a)"); + } if (request.quality && format === "gif") { throw new Error( "--quality is only supported for MP4 exports (this project's stored format is gif; a GIF is sized with --gif-size)", @@ -82,8 +92,7 @@ async function runExport(request: CliExportRequest): Promise { const gifSizePreset = request.gifSizePreset ?? editor.gifSizePreset; const gifLoop = request.gifLoop ?? editor.gifLoop; const mp4FrameRate = request.mp4FrameRate; - const outPath = - request.outPath ?? replaceExtension(request.projectPath, format === "gif" ? ".gif" : ".mp4"); + const outPath = request.outPath ?? replaceExtension(request.projectPath, `.${format}`); // Use the same per-clip auto-zoom suggestions as the editor, including its default span and // anchoring behavior. if (request.autoZoom) { @@ -115,6 +124,10 @@ async function runExport(request: CliExportRequest): Promise { }); const outDims = dimsFor(quality); + // One track alone: the export of a project whose only sound is that track. + const soloTrack = Boolean(request.audioTrackId); + if (request.audioTrackId) axcutDocument = isolateAudioTrack(axcutDocument, request.audioTrackId); + const builtClips = buildNativeClipList(axcutDocument); if (builtClips.length === 0) { throw new Error("The project's timeline has no visible clips to export"); @@ -122,12 +135,17 @@ async function runExport(request: CliExportRequest): Promise { // The scene carries the click hits, whose cues and staged samples are read out of caches a // scene build cannot await — without this a cold CLI export builds its scene from empty // caches and comes out silent. Clicks that will not load must not cost the export. - await prepareClickSound(axcutDocument).catch(() => undefined); - const sceneDesc = buildSceneDescription(axcutDocument); + if (!soloTrack) await prepareClickSound(axcutDocument).catch(() => undefined); + const built = buildSceneDescription(axcutDocument); + // A track alone is heard without the take's sound and the clicks. + const sceneDesc = soloTrack ? { ...built, clickSound: undefined } : built; + if (soloTrack && sceneDesc.audioTracks.length === 0) { + throw new Error(`The audio track "${request.audioTrackId}" plays nowhere in the export`); + } // The webcam background effect is applied by the compositor from the scene, so the clip // list needs no pre-rendering pass. - const clips = builtClips; + const clips = soloTrack ? builtClips.map((clip) => ({ ...clip, hasAudio: false })) : builtClips; const sceneJson = JSON.stringify(sceneDesc); // Progress: native pushes raw encoded-frame counts; totals and pacing are @@ -166,6 +184,29 @@ async function runExport(request: CliExportRequest): Promise { }; } + if (audioFormat) { + // The audio of the MP4 this project exports to, rendered at the smallest size: the + // picture is thrown away, the frame rate stays the one the audio is laid out on. + const renderPath = audioRenderPath(outPath); + const dims = dimsFor("medium"); + await exportMultiNative(clips, renderPath, sceneJson, { + width: dims.width, + height: dims.height, + fps: mp4FrameRate, + codec: "h264", + bitrate: dims.bitrate, + }); + window.electronAPI.cliProgress({ percentage: 100, phase: "extracting-audio" }); + const rendered = await fetch(toFileUrl(renderPath)); + if (!rendered.ok) throw new Error(`Failed to read the rendered video back: ${renderPath}`); + const audio = await extractAudio(await rendered.blob(), audioFormat); + const saveResult = await window.electronAPI.writeExportToPath(audio, outPath); + if (!saveResult.success) { + throw new Error(saveResult.message ?? `Failed to write the audio to ${outPath}`); + } + return { success: true, outputPath: outPath, format }; + } + // MP4: native writes outPath directly. When a voiceover is requested, mix // it afterwards (the native pipeline has no extra-audio-track concept) and // overwrite the same file. diff --git a/src/lib/cliContracts.ts b/src/lib/cliContracts.ts index df1e6e87d2..53a38612c2 100644 --- a/src/lib/cliContracts.ts +++ b/src/lib/cliContracts.ts @@ -6,6 +6,17 @@ import type { ExportQuality, GifFrameRate, GifSizePreset, Mp4FrameRate } from ". export type CliCursorCaptureMode = "editable-overlay" | "system"; +export type AudioExportFormat = "wav" | "m4a"; + +export function isAudioExportFormat(format: string | null): format is AudioExportFormat { + return format === "wav" || format === "m4a"; +} + +/** Where the audio export renders its MP4 before taking the audio out: beside the output. */ +export function audioRenderPath(outPath: string): string { + return `${outPath}.render.mp4`; +} + export interface CliExportRequest { kind: "export"; /** Absolute path to the .openscreen project file. */ @@ -19,8 +30,8 @@ export interface CliExportRequest { projectId?: string; /** Absolute output path; null = derive from projectPath + format. */ outPath: string | null; - /** null = use the format stored in the project. */ - format: "mp4" | "gif" | null; + /** null = use the format stored in the project. wav and m4a write the project's audio only. */ + format: "mp4" | "gif" | "wav" | "m4a" | null; /** null = use the quality stored in the project. */ quality: ExportQuality | null; /** MP4 frame rate, as the Export dialog offers it. */ @@ -46,6 +57,11 @@ export interface CliExportRequest { audioMode: "mix" | "replace"; /** Delay before the voiceover starts, in seconds. */ audioOffsetSec: number; + /** + * wav/m4a only: write this imported track alone (its `audioId`), as the export mixes it, + * instead of the whole programme. Absent or null = the programme audio. + */ + audioTrackId?: string | null; } export interface CliRecordRequest { diff --git a/src/lib/exporter/audioExport.test.ts b/src/lib/exporter/audioExport.test.ts new file mode 100644 index 0000000000..d30390c038 --- /dev/null +++ b/src/lib/exporter/audioExport.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { type AxcutDocument, createEmptyDocument } from "@/lib/ai-edition/schema"; +import { isolateAudioTrack } from "./audioExport"; + +function track(id: string, trackId: string, muted = false): AxcutDocument["audioTracks"][number] { + return { + id, + trackId, + startMs: 0, + endMs: 1000, + assetId: "asset_voice", + kind: "voiceover", + durationSec: 1, + offsetMs: 0, + gainDb: 0, + loop: false, + fadeInMs: 0, + fadeOutMs: 0, + muted, + label: "", + origin: "agent", + }; +} + +describe("isolateAudioTrack", () => { + const document: AxcutDocument = { + ...createEmptyDocument({ projectId: "p", title: "Demo" }), + // "voice" is one pill split across two clips; "music" is another track. + audioTracks: [track("a", "voice"), track("b", "voice"), track("c", "music")], + }; + + it("keeps every fragment of the asked track and nothing else", () => { + expect(isolateAudioTrack(document, "voice").audioTracks.map((t) => t.id)).toEqual(["a", "b"]); + }); + + it("refuses an unknown or muted track", () => { + expect(() => isolateAudioTrack(document, "nope")).toThrow('no audio track with the id "nope"'); + const muted = { ...document, audioTracks: [track("c", "music", true)] }; + expect(() => isolateAudioTrack(muted, "music")).toThrow("is muted"); + }); +}); diff --git a/src/lib/exporter/audioExport.ts b/src/lib/exporter/audioExport.ts new file mode 100644 index 0000000000..5f2d9485ff --- /dev/null +++ b/src/lib/exporter/audioExport.ts @@ -0,0 +1,55 @@ +// Audio-only exports (issue #1280): a project's audio written out as a WAV or an M4A. +// +// The compositor has no audio-only render, and a second mixer here would drift from the one +// the MP4 uses (voice levelling, music ducking, the limiter, where a track lands past a speed +// region). So the audio is the MP4's: the export renders the project as usual, and its audio +// stream is taken out of that file. A track exported alone is the same render with everything +// else silenced, so it keeps the offset, trims, gain and fades the video export gives it. + +import { + ALL_FORMATS, + BlobSource, + BufferTarget, + Conversion, + Input, + Mp4OutputFormat, + Output, + WavOutputFormat, +} from "mediabunny"; +import { trackGroupId } from "@/lib/ai-edition/document/audioTracks"; +import type { AxcutDocument } from "@/lib/ai-edition/schema"; +import type { AudioExportFormat } from "@/lib/cliContracts"; + +/** The project with one imported track left of its audio tracks; throws when it has no such + * track. The take's own sound and the clicks are silenced at render time, not here. */ +export function isolateAudioTrack(document: AxcutDocument, audioId: string): AxcutDocument { + const audioTracks = document.audioTracks.filter((track) => trackGroupId(track) === audioId); + if (audioTracks.length === 0) { + throw new Error(`This project has no audio track with the id "${audioId}"`); + } + if (audioTracks.every((track) => track.muted)) { + throw new Error(`The audio track "${audioId}" is muted: unmute it to export it`); + } + return { ...document, audioTracks }; +} + +/** The audio stream of an exported MP4, as a WAV (decoded) or an M4A (the AAC copied as is). */ +export async function extractAudio(video: Blob, format: AudioExportFormat): Promise { + const input = new Input({ source: new BlobSource(video), formats: ALL_FORMATS }); + try { + const target = new BufferTarget(); + const output = new Output({ + format: format === "wav" ? new WavOutputFormat() : new Mp4OutputFormat(), + target, + }); + const conversion = await Conversion.init({ input, output, video: { discard: true } }); + if (!conversion.isValid) { + throw new Error("The rendered video has no audio to export"); + } + await conversion.execute(); + if (!target.buffer) throw new Error("The audio export produced no output"); + return target.buffer; + } finally { + input.dispose(); + } +} diff --git a/technical-documentation/architecture/mcp-server.md b/technical-documentation/architecture/mcp-server.md index e9ac925643..9a3740eede 100644 --- a/technical-documentation/architecture/mcp-server.md +++ b/technical-documentation/architecture/mcp-server.md @@ -12,7 +12,7 @@ The same tools are served over two transports, by two different processes: | Edits, recording, export | Off until the user turns on **MCP edits** | Always allowed | | Project open in the editor | Edited live through the editor, one undo step per call | The same while the app runs: the call is forwarded to it. With no app answering, refused ([the marker](#the-open-in-editor-marker)) | | Other projects | Read from and saved to their file, a checkpoint first | Same | -| Tools | `listProjects`, the agent's tools, `transcribe`, the project tools (`setProjectInfo`, `importMedia`, `removeMedia`), the checkpoint tools, and the media tools: `listSources`, `startRecording`, `stopRecording`, `setPointerTrack`, `exportVideo`, `getFrames`, `createProject` | The same | +| Tools | `listProjects`, the agent's tools, `transcribe`, the project tools (`setProjectInfo`, `importMedia`, `removeMedia`), the checkpoint tools, and the media tools: `listSources`, `startRecording`, `stopRecording`, `setPointerTrack`, `exportVideo`, `exportAudio`, `getFrames`, `createProject` | The same | | A take | Recorded by the app's recording bar, as its Record button records; the editor opens it ([below](#the-media-tools)) | Recorded by a hidden runner of its own process, and made a project there | | Checkpoints | In the app's memory, shared with forwarded `openscreen mcp` calls, lost when the app quits | In the app's while it runs; otherwise in this process's, lost when it exits, which it does when the client disconnects | @@ -174,7 +174,8 @@ Then both servers carry on alike: - `setPointerTrack` gives a script-driven take a pointer track and plans zooms on its clicks. `pointerTrackEdit` computes the new sidecar and the zooms from the project the runner read, writes nothing itself, and hands both to the runner as a `RunnerEdit`. The runner then applies the same refusals as any edit (another process's editor, no checkpoint, a project changed mid-call) before writing anything, saves the project, and only then writes the sidecar, keeping its previous content for the checkpoints. Every refusal comes back as the tool's own text result. A sidecar write that fails after the save is reported as a partial edit: the project was saved, the pointer track was not. A `RunnerEdit` is a closure and cannot be forwarded, so the call carries what the edit needs (`events`, and `takes`, each take's start time by video): the app's channel serves `setPointerTrack` with those args and runs `pointerTrackEdit` in its own runner. That is the channel's own registration, not the in-app server's tool: the takes of `openscreen mcp` are known to it alone. - `exportVideo` renders in a hidden `cli-export` runner, the same in both processes: in the app, `listSources` and `exportVideo` run the CLI's runners (`createRunnerHost` with `createRunnerWindow`), whose IPC the app registers on first use. The runner reads the project by id (`CliExportRequest.projectId`, the editor's own `aiEdition.get`), not by path: opening a file by path moves the main process's current project and recording-session slot, which in the app is the hand-off from the recorder to the editor. - `exportVideo` takes the Export dialog's options and choices: `quality` and `fps` (24, 30, 60) for an MP4, `gifFrameRate`, `gifSize` and `gifLoop` for a GIF, the dialog's defaults when left out. An option of the other format is refused rather than ignored. Hiding the desktop icons is not a `startRecording` option: the HUD has no such control, so it follows the user's Record mode setting on both servers. Nor are the CLI's `--auto-zoom` and `--audio`: zooms and a voiceover are edits of the project, and an export that added them would not be the project `getFrames` maps through. -- `exportVideo` and `getFrames` read projects; they write none. Over HTTP their paths must be absolute. +- `exportAudio` (#1280) is the same runner with `format` `wav` or `m4a`: `CliExportRunner` renders the MP4 beside the output (`audioRenderPath`, removed by `createRunnerHost` once the run reports) and `extractAudio` takes its audio stream out, so the file is the MP4's own mix, not a second mixer's. With `audioId` (`CliExportRequest.audioTrackId`, the CLI's `--track`), `isolateAudioTrack` keeps that track's fragments only, the clips render with `hasAudio: false` and the click sound is dropped: the track alone, where the export places it. There is no option to replace the take's sound for the whole programme: the document has no such switch (the take's gain is bounded at ±12 dB), and an export option would make a file the project does not describe. +- `exportVideo`, `exportAudio` and `getFrames` read projects; they write none. Over HTTP their paths must be absolute. - `getFrames` with a `projectId` reads the project's last export while it is current. With no export, or one the project changed after, it renders the frames from the project instead (`renderFrames` in the addon, through the export's own walk), so an agent checks an edit without exporting. See [docs/shader-effects.md](../../docs/shader-effects.md), E.4. - `createProject` makes a project with `DocumentService.createProject`, so it starts in the look new projects take (the preset marked for them, else the last edited project's), as the editor's New does. With a `videoPath` it lays the video out with `createProjectFromVideo`, the path a stdio take goes through: one clip, measured, and the zooms on its clicks when a pointer track sits beside it. Either way it returns a checkpoint, as `stopRecording` does, so the client can edit the closed project at once. It runs where the media tools run (in the CLI process for `openscreen mcp`): a new project is one no editor holds. **MCP edits** gates it. diff --git a/website/docs/ai-editing.md b/website/docs/ai-editing.md index 2bb088f68a..d7e1647c62 100644 --- a/website/docs/ai-editing.md +++ b/website/docs/ai-editing.md @@ -76,7 +76,7 @@ Checkpoints are kept in OpenScreen's memory: they are lost when it quits or when ## Letting an agent make the whole video -An agent can make a whole video on its own, from the recording to the export. Besides the editing tools, both ways of adding OpenScreen to it give it `listSources`, `startRecording`, `stopRecording`, `setPointerTrack`, `exportVideo`, `getFrames` and `createProject`. A take can also record your webcam and the system's own cursor, as the recording bar can, and an export takes the Export dialog's options: quality and frame rate for an MP4; frame rate, size and looping for a GIF. +An agent can make a whole video on its own, from the recording to the export. Besides the editing tools, both ways of adding OpenScreen to it give it `listSources`, `startRecording`, `stopRecording`, `setPointerTrack`, `exportVideo`, `exportAudio`, `getFrames` and `createProject`. A take can also record your webcam and the system's own cursor, as the recording bar can, and an export takes the Export dialog's options: quality and frame rate for an MP4; frame rate, size and looping for a GIF. `exportAudio` writes the project's audio, or one imported track alone (a voiceover added with `importMedia` and `addAudio`), to a WAV or an M4A, exactly as the MP4 mixes it. **Through the server above**, a take is recorded by OpenScreen itself, as with its Record button: the editor closes, the recording bar counts down and records, and the take opens in the editor as a new project, where you watch each edit land. It needs **MCP edits** on, and the recording bar gets your own settings back once the take ends. diff --git a/website/docs/cli.md b/website/docs/cli.md index 2c4cdfbe18..78974e196c 100644 --- a/website/docs/cli.md +++ b/website/docs/cli.md @@ -131,12 +131,15 @@ openscreen export demo.openscreen # format and quality openscreen export demo.openscreen -o out.mp4 --quality source openscreen export demo.openscreen -o out.gif --gif-fps 20 --gif-size large openscreen export demo.openscreen -o out.mp4 --auto-zoom --json +openscreen export demo.openscreen -o mix.m4a # the audio only +openscreen export demo.openscreen -o voice.wav --track audio_1234 # one imported track alone ``` | Option | Meaning | |---|---| -| `-o, --out ` | Output file. The extension, `.mp4` or `.gif`, sets the format. Default: the project's path with `.mp4` or `.gif` | -| `--format ` | Override the format stored in the project. Must agree with `--out` | +| `-o, --out ` | Output file. The extension, `.mp4`, `.gif`, `.wav` or `.m4a`, sets the format. Default: the project's path with the format's extension | +| `--format ` | Override the format stored in the project. Must agree with `--out`. `wav` and `m4a` write the project's audio only, as the MP4 mixes it | +| `--track ` | WAV and M4A only: write this imported audio track alone, where it plays in the MP4, with its offset, trims, gain and fades. The file lasts as long as the video. The id is the track's `audioId` (from `addAudio`, or `audioTracks` in the project) | | `--quality ` | Output size: `medium` is 720p, `good` is 1080p, `source` follows the smallest clip after cropping, so it never upscales. MP4 only: a GIF starts from the source size, as in the app | | `--fps <24\|30\|60>` | MP4 frame rate (default 60) | | `--gif-fps <15\|20\|25\|30>` | GIF frame rate | @@ -152,6 +155,8 @@ MP4 exports from the CLI are always **H.264**, at 60 fps unless `--fps` says oth `--audio` works after the render: the video stream is copied untouched, and a new AAC track is mixed and written over the same output file. +A `.wav` or `.m4a` export renders the project as the MP4 would, at 720p, and keeps only its audio, so a track lands where it lands in the video, through every cut and speed region. With `--track`, the recording's own sound, the click sounds and the other tracks are left out. An M4A keeps the MP4's AAC as is; a WAV is decoded to 16-bit PCM. + **Where media may live.** When it loads a project, the app approves the referenced media automatically only inside its recordings directory or the project file's own folder. Keep a hand-written project next to its media, or record with the CLI, which uses the recordings directory. **No cancel.** Only `record` listens for a stop request. Ending the process is the only way to abandon an export; treat whatever it left at the output path as unusable. @@ -209,7 +214,7 @@ With `--json`, stdout carries one JSON object per line. stderr carries diagnosti |---|---|---| | `started` | A `record`, `sources`, `export` or `captions` run begins | `command` | | `log` | A status line, such as `Recording started` | `message` | -| `progress` | Export frames are encoded | `percentage`, `currentFrame`, `totalFrames`, `estimatedTimeRemaining` in seconds. While `--audio` is mixed: `percentage` and `phase: "mixing-voiceover"` | +| `progress` | Export frames are encoded | `percentage`, `currentFrame`, `totalFrames`, `estimatedTimeRemaining` in seconds. While `--audio` is mixed: `percentage` and `phase: "mixing-voiceover"`. While a `.wav` or `.m4a` is written: `phase: "extracting-audio"` | | `stopping` | `record` received a stop request | `reason`: `SIGINT`, `SIGTERM` or `stdin` | | `warning` | The run succeeded with a caveat | `message` | | `error` | A failure was reported | `message` | From fb16633ed2523c607d4d722d94ab33cb046a56b8 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 24 Oct 2026 20:24:14 +0200 Subject: [PATCH 2/2] fix(cli): clean up an audio export's render without --out, and name the track id audioId takes --- electron/cli/runnerHost.test.ts | 7 ++++++- electron/cli/runnerHost.ts | 12 ++++++++---- electron/mcp/media-tools.ts | 2 +- src/cli/CliExportRunner.tsx | 7 ++----- src/lib/cliContracts.ts | 5 +++++ website/docs/cli.md | 2 +- 6 files changed, 23 insertions(+), 12 deletions(-) diff --git a/electron/cli/runnerHost.test.ts b/electron/cli/runnerHost.test.ts index 80b760cf0f..a6a046ead7 100644 --- a/electron/cli/runnerHost.test.ts +++ b/electron/cli/runnerHost.test.ts @@ -88,7 +88,7 @@ describe("createRunnerHost", () => { await writeFile(audioRenderPath(outPath), "mp4"); await writeFile(path.join(dir, "demo.mp4"), "mp4"); const { ipc, host } = setup(); - const exportOf = (format: "wav" | "mp4", out: string): CliRequest => ({ + const exportOf = (format: "wav" | "mp4", out: string | null): CliRequest => ({ kind: "export", projectPath: path.join(dir, "p.openscreen"), outPath: out, @@ -106,10 +106,15 @@ describe("createRunnerHost", () => { 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 }); }); diff --git a/electron/cli/runnerHost.ts b/electron/cli/runnerHost.ts index 7dd6e3a900..b192e9f4e6 100644 --- a/electron/cli/runnerHost.ts +++ b/electron/cli/runnerHost.ts @@ -12,6 +12,7 @@ import { type CliDoneResult, type CliProgressEvent, type CliRequest, + defaultExportPath, isAudioExportFormat, } from "../../src/lib/cliContracts"; @@ -109,12 +110,15 @@ export function createRunnerHost( 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" && isAudioExportFormat(request.format) + request.kind === "export" && audioFormat ? reported.then(async (result) => { - const outPath = request.outPath ?? result.outputPath; - if (outPath) - await rm(audioRenderPath(outPath), { force: true }).catch(() => undefined); + // 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; diff --git a/electron/mcp/media-tools.ts b/electron/mcp/media-tools.ts index 7bfaf9ae37..9aff909126 100644 --- a/electron/mcp/media-tools.ts +++ b/electron/mcp/media-tools.ts @@ -917,7 +917,7 @@ export function createMediaTools(deps: MediaToolDeps): MediaTools { "exportAudio", { description: - "Write a project's audio to a WAV or an M4A, exactly as exportVideo's MP4 mixes it: the recording's sound, the imported tracks with their offset, trims, gain and fades, through the same cuts and speed regions. With audioId (addAudio's, or an audioTracks id), only that track, alone, placed where it plays in the export: the file lasts as long as the video, silent around the track. It renders the project as an export does, so it takes about as long as exportVideo.", + "Write a project's audio to a WAV or an M4A, exactly as exportVideo's MP4 mixes it: the recording's sound, the imported tracks with their offset, trims, gain and fades, through the same cuts and speed regions. With audioId (addAudio's, or an audioTracks entry's trackId, its id when it has no trackId), only that track, alone, placed where it plays in the export: the file lasts as long as the video, silent around the track. It renders the project as an export does, so it takes about as long as exportVideo.", inputSchema: z.object({ projectId: z.string(), path: z.string().describe(AUDIO_PATH_DESCRIPTIONS[transport]), diff --git a/src/cli/CliExportRunner.tsx b/src/cli/CliExportRunner.tsx index e0e28dc7d3..3f0041e512 100644 --- a/src/cli/CliExportRunner.tsx +++ b/src/cli/CliExportRunner.tsx @@ -22,6 +22,7 @@ import { audioRenderPath, type CliDoneResult, type CliExportRequest, + defaultExportPath, isAudioExportFormat, } from "@/lib/cliContracts"; import { GIF_SIZE_PRESETS, type GifSizePreset } from "@/lib/exporter"; @@ -34,10 +35,6 @@ import { buildNativeClipList, buildSceneDescription } from "@/native/sceneDescri import { type ExportProject, loadDocumentProject } from "./exportProject"; import { loadCliProject, probeVideoDimensions } from "./loadCliProject"; -function replaceExtension(filePath: string, newExtension: string): string { - return filePath.replace(/\.(openscreen|json)$/i, "") + newExtension; -} - /** Mirrors ExportDialog.gifOutputDims: from the "Source" size, cap height at the preset, keep even. */ function gifOutputDims( preset: GifSizePreset, @@ -92,7 +89,7 @@ async function runExport(request: CliExportRequest): Promise { const gifSizePreset = request.gifSizePreset ?? editor.gifSizePreset; const gifLoop = request.gifLoop ?? editor.gifLoop; const mp4FrameRate = request.mp4FrameRate; - const outPath = request.outPath ?? replaceExtension(request.projectPath, `.${format}`); + const outPath = request.outPath ?? defaultExportPath(request.projectPath, format); // Use the same per-clip auto-zoom suggestions as the editor, including its default span and // anchoring behavior. if (request.autoZoom) { diff --git a/src/lib/cliContracts.ts b/src/lib/cliContracts.ts index 53a38612c2..c1cf7b91a2 100644 --- a/src/lib/cliContracts.ts +++ b/src/lib/cliContracts.ts @@ -12,6 +12,11 @@ export function isAudioExportFormat(format: string | null): format is AudioExpor return format === "wav" || format === "m4a"; } +/** Where an export without --out lands: beside the project, with the format's extension. */ +export function defaultExportPath(projectPath: string, format: string): string { + return `${projectPath.replace(/\.(openscreen|json)$/i, "")}.${format}`; +} + /** Where the audio export renders its MP4 before taking the audio out: beside the output. */ export function audioRenderPath(outPath: string): string { return `${outPath}.render.mp4`; diff --git a/website/docs/cli.md b/website/docs/cli.md index 78974e196c..f2bf6198a0 100644 --- a/website/docs/cli.md +++ b/website/docs/cli.md @@ -139,7 +139,7 @@ openscreen export demo.openscreen -o voice.wav --track audio_1234 # one impo |---|---| | `-o, --out ` | Output file. The extension, `.mp4`, `.gif`, `.wav` or `.m4a`, sets the format. Default: the project's path with the format's extension | | `--format ` | Override the format stored in the project. Must agree with `--out`. `wav` and `m4a` write the project's audio only, as the MP4 mixes it | -| `--track ` | WAV and M4A only: write this imported audio track alone, where it plays in the MP4, with its offset, trims, gain and fades. The file lasts as long as the video. The id is the track's `audioId` (from `addAudio`, or `audioTracks` in the project) | +| `--track ` | WAV and M4A only: write this imported audio track alone, where it plays in the MP4, with its offset, trims, gain and fades. The file lasts as long as the video. The id is the track's `audioId` (from `addAudio`), or an `audioTracks` entry's `trackId` in the project, its `id` when it has no `trackId` | | `--quality ` | Output size: `medium` is 720p, `good` is 1080p, `source` follows the smallest clip after cropping, so it never upscales. MP4 only: a GIF starts from the source size, as in the app | | `--fps <24\|30\|60>` | MP4 frame rate (default 60) | | `--gif-fps <15\|20\|25\|30>` | GIF frame rate |