diff --git a/README.md b/README.md index b42355e7f..b941c73a6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ +npm run build:win + +D:\Development\openscreen\release + + > [!WARNING] > This is very much in beta and might be buggy here and there (but hope you have a good experience!). diff --git a/src/lib/exporter/audioEncoder.ts b/src/lib/exporter/audioEncoder.ts index 490eed2a9..5af92f7f7 100644 --- a/src/lib/exporter/audioEncoder.ts +++ b/src/lib/exporter/audioEncoder.ts @@ -6,8 +6,15 @@ const AUDIO_BITRATE = 128_000; const DECODE_BACKPRESSURE_LIMIT = 20; const MIN_SPEED_REGION_DELTA_MS = 0.0001; +export type TargetAudioCodec = "aac" | "opus"; + export class AudioProcessor { private cancelled = false; + private targetCodec: TargetAudioCodec; + + constructor(targetCodec: TargetAudioCodec) { + this.targetCodec = targetCodec; + } /** * Audio export has two modes: @@ -132,16 +139,24 @@ export class AudioProcessor { const sampleRate = audioConfig.sampleRate || 48000; const channels = audioConfig.numberOfChannels || 2; - const encodeConfig: AudioEncoderConfig = { - codec: "opus", - sampleRate, - numberOfChannels: channels, - bitrate: AUDIO_BITRATE, - }; + const encodeConfig: AudioEncoderConfig = + this.targetCodec === "aac" + ? { + codec: "mp4a.40.2", + sampleRate, + numberOfChannels: channels, + bitrate: AUDIO_BITRATE, + } + : { + codec: "opus", + sampleRate, + numberOfChannels: channels, + bitrate: AUDIO_BITRATE, + }; const encodeSupport = await AudioEncoder.isConfigSupported(encodeConfig); if (!encodeSupport.supported) { - console.warn("[AudioProcessor] Opus encoding not supported, skipping audio"); + console.warn("[AudioProcessor] Audio encoding not supported, skipping audio"); for (const frame of decodedFrames) frame.close(); return; } @@ -314,28 +329,7 @@ export class AudioProcessor { try { await demuxer.load(file); - const audioConfig = (await demuxer.getDecoderConfig("audio")) as AudioDecoderConfig; - const reader = (demuxer.read("audio") as ReadableStream).getReader(); - let isFirstChunk = true; - - try { - while (!this.cancelled) { - const { done, value: chunk } = await reader.read(); - if (done || !chunk) break; - if (isFirstChunk) { - await muxer.addAudioChunk(chunk, { decoderConfig: audioConfig }); - isFirstChunk = false; - } else { - await muxer.addAudioChunk(chunk); - } - } - } finally { - try { - await reader.cancel(); - } catch { - /* reader already closed */ - } - } + await this.processTrimOnlyAudio(demuxer, muxer, [], undefined); } finally { try { demuxer.destroy(); diff --git a/src/lib/exporter/muxer.ts b/src/lib/exporter/muxer.ts index a51123877..8f75f068d 100644 --- a/src/lib/exporter/muxer.ts +++ b/src/lib/exporter/muxer.ts @@ -8,17 +8,19 @@ import { } from "mediabunny"; import type { ExportConfig } from "./types"; +export type Mp4AudioCodec = "aac" | "opus"; + export class VideoMuxer { private output: Output | null = null; private videoSource: EncodedVideoPacketSource | null = null; private audioSource: EncodedAudioPacketSource | null = null; - private hasAudio: boolean; + private audioCodec: Mp4AudioCodec | null; private target: BufferTarget | null = null; private config: ExportConfig; - constructor(config: ExportConfig, hasAudio = false) { + constructor(config: ExportConfig, audioCodec: Mp4AudioCodec | null = null) { this.config = config; - this.hasAudio = hasAudio; + this.audioCodec = audioCodec; } async initialize(): Promise { @@ -39,8 +41,8 @@ export class VideoMuxer { }); // Create audio source if needed - if (this.hasAudio) { - this.audioSource = new EncodedAudioPacketSource("opus"); + if (this.audioCodec) { + this.audioSource = new EncodedAudioPacketSource(this.audioCodec); this.output.addAudioTrack(this.audioSource); } diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index d0affd17c..7a477f893 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -1,3 +1,4 @@ +import type { WebDemuxer } from "web-demuxer"; import type { AnnotationRegion, CropRegion, @@ -10,12 +11,13 @@ import type { import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue"; import { AudioProcessor } from "./audioEncoder"; import { FrameRenderer } from "./frameRenderer"; -import { VideoMuxer } from "./muxer"; +import { type Mp4AudioCodec, VideoMuxer } from "./muxer"; import { StreamingVideoDecoder } from "./streamingDecoder"; import type { ExportConfig, ExportProgress, ExportResult } from "./types"; const ENCODER_STALL_TIMEOUT_MS = 15_000; const ENCODER_FLUSH_TIMEOUT_MS = 20_000; +const AUDIO_BITRATE = 128_000; interface VideoExporterConfig extends ExportConfig { videoUrl: string; @@ -66,6 +68,40 @@ export class VideoExporter { this.config = config; } + private async pickAudioCodec(demuxer: WebDemuxer | null): Promise { + if (!demuxer) return null; + + let audioConfig: AudioDecoderConfig; + try { + audioConfig = (await demuxer.getDecoderConfig("audio")) as AudioDecoderConfig; + } catch { + return null; + } + + const sampleRate = audioConfig.sampleRate || 48000; + const channels = audioConfig.numberOfChannels || 2; + + const aacConfig: AudioEncoderConfig = { + codec: "mp4a.40.2", + sampleRate, + numberOfChannels: channels, + bitrate: AUDIO_BITRATE, + }; + const aacSupport = await AudioEncoder.isConfigSupported(aacConfig); + if (aacSupport.supported) return "aac"; + + const opusConfig: AudioEncoderConfig = { + codec: "opus", + sampleRate, + numberOfChannels: channels, + bitrate: AUDIO_BITRATE, + }; + const opusSupport = await AudioEncoder.isConfigSupported(opusConfig); + if (opusSupport.supported) return "opus"; + + return null; + } + async export(): Promise { const encoderPreferences = this.getEncoderPreferences(); let lastError: Error | null = null; @@ -153,7 +189,14 @@ export class VideoExporter { await this.initializeEncoder(encoderPreference); const hasAudio = videoInfo.hasAudio; - const muxer = new VideoMuxer(this.config, hasAudio); + const audioCodec = hasAudio ? await this.pickAudioCodec(streamingDecoder.getDemuxer()) : null; + if (hasAudio && !audioCodec) { + throw new Error( + "Source has audio, but no supported audio encoder was found for MP4 export.", + ); + } + + const muxer = new VideoMuxer(this.config, audioCodec); this.muxer = muxer; await muxer.initialize(); @@ -335,11 +378,11 @@ export class VideoExporter { phase: "finalizing", }); - if (hasAudio && !this.cancelled) { + if (audioCodec && !this.cancelled) { const demuxer = streamingDecoder.getDemuxer(); if (demuxer) { console.log("[VideoExporter] Processing audio track..."); - this.audioProcessor = new AudioProcessor(); + this.audioProcessor = new AudioProcessor(audioCodec); await this.audioProcessor.process( demuxer, muxer, diff --git a/vitest.config.ts b/vitest.config.ts index ea60216f4..9108f6991 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ globals: true, environment: "jsdom", include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + exclude: ["src/**/*.browser.test.{ts,tsx}"], }, resolve: { alias: {