mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor: make export audio consistent to preview
fixes audio export issue with .mkv files
This commit is contained in:
@@ -8,19 +8,23 @@ import type { MediaAsset } from "@/types/assets";
|
|||||||
import { canElementHaveAudio } from "@/lib/timeline/element-utils";
|
import { canElementHaveAudio } from "@/lib/timeline/element-utils";
|
||||||
import { canTracktHaveAudio } from "@/lib/timeline";
|
import { canTracktHaveAudio } from "@/lib/timeline";
|
||||||
import { mediaSupportsAudio } from "@/lib/media/media-utils";
|
import { mediaSupportsAudio } from "@/lib/media/media-utils";
|
||||||
|
import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny";
|
||||||
|
|
||||||
|
const MAX_AUDIO_CHANNELS = 2;
|
||||||
|
const EXPORT_SAMPLE_RATE = 44100;
|
||||||
|
|
||||||
export type CollectedAudioElement = Omit<
|
export type CollectedAudioElement = Omit<
|
||||||
AudioElement,
|
AudioElement,
|
||||||
"type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl"
|
"type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl"
|
||||||
> & { buffer: AudioBuffer };
|
> & { buffer: AudioBuffer };
|
||||||
|
|
||||||
export function createAudioContext(): AudioContext {
|
export function createAudioContext({ sampleRate }: { sampleRate?: number } = {}): AudioContext {
|
||||||
const AudioContextConstructor =
|
const AudioContextConstructor =
|
||||||
window.AudioContext ||
|
window.AudioContext ||
|
||||||
(window as typeof window & { webkitAudioContext?: typeof AudioContext })
|
(window as typeof window & { webkitAudioContext?: typeof AudioContext })
|
||||||
.webkitAudioContext;
|
.webkitAudioContext;
|
||||||
|
|
||||||
return new AudioContextConstructor();
|
return new AudioContextConstructor(sampleRate ? { sampleRate } : undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DecodedAudio {
|
export interface DecodedAudio {
|
||||||
@@ -170,12 +174,64 @@ async function resolveAudioBufferForVideoElement({
|
|||||||
mediaAsset: MediaAsset;
|
mediaAsset: MediaAsset;
|
||||||
audioContext: AudioContext;
|
audioContext: AudioContext;
|
||||||
}): Promise<AudioBuffer | null> {
|
}): Promise<AudioBuffer | null> {
|
||||||
|
const input = new Input({
|
||||||
|
source: new BlobSource(mediaAsset.file),
|
||||||
|
formats: ALL_FORMATS,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const arrayBuffer = await mediaAsset.file.arrayBuffer();
|
const audioTrack = await input.getPrimaryAudioTrack();
|
||||||
return await audioContext.decodeAudioData(arrayBuffer.slice(0));
|
if (!audioTrack) return null;
|
||||||
|
|
||||||
|
const sink = new AudioBufferSink(audioTrack);
|
||||||
|
const targetSampleRate = audioContext.sampleRate;
|
||||||
|
|
||||||
|
const chunks: AudioBuffer[] = [];
|
||||||
|
let totalSamples = 0;
|
||||||
|
|
||||||
|
for await (const { buffer } of sink.buffers(0)) {
|
||||||
|
chunks.push(buffer);
|
||||||
|
totalSamples += buffer.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunks.length === 0) return null;
|
||||||
|
|
||||||
|
const nativeSampleRate = chunks[0].sampleRate;
|
||||||
|
const numChannels = Math.min(MAX_AUDIO_CHANNELS, chunks[0].numberOfChannels);
|
||||||
|
|
||||||
|
const nativeChannels = Array.from(
|
||||||
|
{ length: numChannels },
|
||||||
|
() => new Float32Array(totalSamples),
|
||||||
|
);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
for (let channel = 0; channel < numChannels; channel++) {
|
||||||
|
const sourceData = chunk.getChannelData(Math.min(channel, chunk.numberOfChannels - 1));
|
||||||
|
nativeChannels[channel].set(sourceData, offset);
|
||||||
|
}
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// use OfflineAudioContext for high-quality resampling to target rate
|
||||||
|
const outputSamples = Math.ceil(totalSamples * (targetSampleRate / nativeSampleRate));
|
||||||
|
const offlineContext = new OfflineAudioContext(numChannels, outputSamples, targetSampleRate);
|
||||||
|
|
||||||
|
const nativeBuffer = audioContext.createBuffer(numChannels, totalSamples, nativeSampleRate);
|
||||||
|
for (let ch = 0; ch < numChannels; ch++) {
|
||||||
|
nativeBuffer.copyToChannel(nativeChannels[ch], ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceNode = offlineContext.createBufferSource();
|
||||||
|
sourceNode.buffer = nativeBuffer;
|
||||||
|
sourceNode.connect(offlineContext.destination);
|
||||||
|
sourceNode.start(0);
|
||||||
|
|
||||||
|
return await offlineContext.startRendering();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Failed to decode video audio:", error);
|
console.warn("Failed to decode video audio:", error);
|
||||||
return null;
|
return null;
|
||||||
|
} finally {
|
||||||
|
input.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,7 +478,7 @@ export async function createTimelineAudioBuffer({
|
|||||||
tracks,
|
tracks,
|
||||||
mediaAssets,
|
mediaAssets,
|
||||||
duration,
|
duration,
|
||||||
sampleRate = 44100,
|
sampleRate = EXPORT_SAMPLE_RATE,
|
||||||
audioContext,
|
audioContext,
|
||||||
}: {
|
}: {
|
||||||
tracks: TimelineTrack[];
|
tracks: TimelineTrack[];
|
||||||
@@ -431,7 +487,7 @@ export async function createTimelineAudioBuffer({
|
|||||||
sampleRate?: number;
|
sampleRate?: number;
|
||||||
audioContext?: AudioContext;
|
audioContext?: AudioContext;
|
||||||
}): Promise<AudioBuffer | null> {
|
}): Promise<AudioBuffer | null> {
|
||||||
const context = audioContext ?? createAudioContext();
|
const context = audioContext ?? createAudioContext({ sampleRate });
|
||||||
|
|
||||||
const audioElements = await collectAudioElements({
|
const audioElements = await collectAudioElements({
|
||||||
tracks,
|
tracks,
|
||||||
|
|||||||
@@ -13,11 +13,9 @@ import {
|
|||||||
QUALITY_VERY_HIGH,
|
QUALITY_VERY_HIGH,
|
||||||
} from "mediabunny";
|
} from "mediabunny";
|
||||||
import type { RootNode } from "./nodes/root-node";
|
import type { RootNode } from "./nodes/root-node";
|
||||||
|
import type { ExportFormat, ExportQuality } from "@/types/export";
|
||||||
import { CanvasRenderer } from "./canvas-renderer";
|
import { CanvasRenderer } from "./canvas-renderer";
|
||||||
|
|
||||||
export type ExportFormat = "mp4" | "webm";
|
|
||||||
export type ExportQuality = "low" | "medium" | "high" | "very_high";
|
|
||||||
|
|
||||||
type ExportParams = {
|
type ExportParams = {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user