feat: add audio support for export

This commit is contained in:
Maze Winther
2025-08-28 21:52:03 +02:00
parent d4e08bb7e3
commit 70a9bfebb1
3 changed files with 336 additions and 164 deletions
@@ -7,6 +7,7 @@ import { Button } from "../ui/button";
import { Label } from "../ui/label"; import { Label } from "../ui/label";
import { RadioGroup, RadioGroupItem } from "../ui/radio-group"; import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
import { Progress } from "../ui/progress"; import { Progress } from "../ui/progress";
import { Checkbox } from "../ui/checkbox";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
exportProject, exportProject,
@@ -75,6 +76,9 @@ function ExportPopover({
const [quality, setQuality] = useState<ExportQuality>( const [quality, setQuality] = useState<ExportQuality>(
DEFAULT_EXPORT_OPTIONS.quality DEFAULT_EXPORT_OPTIONS.quality
); );
const [includeAudio, setIncludeAudio] = useState<boolean>(
DEFAULT_EXPORT_OPTIONS.includeAudio || true
);
const [isExporting, setIsExporting] = useState(false); const [isExporting, setIsExporting] = useState(false);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [exportResult, setExportResult] = useState<ExportResult | null>(null); const [exportResult, setExportResult] = useState<ExportResult | null>(null);
@@ -90,6 +94,7 @@ function ExportPopover({
format, format,
quality, quality,
fps: activeProject.fps, fps: activeProject.fps,
includeAudio,
onProgress: setProgress, onProgress: setProgress,
onCancel: () => false, // TODO: Add cancel functionality onCancel: () => false, // TODO: Add cancel functionality
}); });
@@ -197,6 +202,23 @@ function ExportPopover({
</div> </div>
</RadioGroup> </RadioGroup>
</PropertyGroup> </PropertyGroup>
<PropertyGroup
title="Audio"
titleClassName="text-sm"
defaultExpanded={false}
>
<div className="flex items-center space-x-2">
<Checkbox
id="include-audio"
checked={includeAudio}
onCheckedChange={(checked) => setIncludeAudio(!!checked)}
/>
<Label htmlFor="include-audio">
Include audio in export
</Label>
</div>
</PropertyGroup>
</div> </div>
<Button onClick={handleExport} className="w-full gap-2"> <Button onClick={handleExport} className="w-full gap-2">
+152 -3
View File
@@ -4,6 +4,7 @@ import {
WebMOutputFormat, WebMOutputFormat,
BufferTarget, BufferTarget,
CanvasSource, CanvasSource,
AudioBufferSource,
QUALITY_LOW, QUALITY_LOW,
QUALITY_MEDIUM, QUALITY_MEDIUM,
QUALITY_HIGH, QUALITY_HIGH,
@@ -19,6 +20,7 @@ import { ExportOptions, ExportResult } from "@/types/export";
export const DEFAULT_EXPORT_OPTIONS: ExportOptions = { export const DEFAULT_EXPORT_OPTIONS: ExportOptions = {
format: "mp4", format: "mp4",
quality: "high", quality: "high",
includeAudio: true,
}; };
const qualityMap = { const qualityMap = {
@@ -28,10 +30,124 @@ const qualityMap = {
very_high: QUALITY_VERY_HIGH, very_high: QUALITY_VERY_HIGH,
}; };
interface AudioElement {
buffer: AudioBuffer;
startTime: number;
duration: number;
trimStart: number;
trimEnd: number;
muted: boolean;
}
async function createTimelineAudioBuffer(
tracks: any[],
mediaFiles: any[],
duration: number,
sampleRate: number = 44100
): Promise<AudioBuffer | null> {
// Get Web Audio context
const audioContext = new (window.AudioContext ||
(window as any).webkitAudioContext)();
// Collect all audio elements from timeline
const audioElements: AudioElement[] = [];
const mediaMap = new Map(mediaFiles.map((m) => [m.id, m]));
for (const track of tracks) {
if (track.muted) continue;
for (const element of track.elements) {
if (element.type !== "media") continue;
const mediaItem = mediaMap.get(element.mediaId);
if (!mediaItem || mediaItem.type !== "audio") continue;
const visibleDuration =
element.duration - element.trimStart - element.trimEnd;
if (visibleDuration <= 0) continue;
try {
// Decode audio file
const arrayBuffer = await mediaItem.file.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(
arrayBuffer.slice(0)
);
audioElements.push({
buffer: audioBuffer,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
muted: element.muted || track.muted || false,
});
} catch (error) {
console.warn(`Failed to decode audio file ${mediaItem.name}:`, error);
}
}
}
if (audioElements.length === 0) {
return null; // No audio to mix
}
// Create output buffer
const outputChannels = 2; // Stereo
const outputLength = Math.ceil(duration * sampleRate);
const outputBuffer = audioContext.createBuffer(
outputChannels,
outputLength,
sampleRate
);
// Mix all audio elements
for (const element of audioElements) {
if (element.muted) continue;
const {
buffer,
startTime,
trimStart,
trimEnd,
duration: elementDuration,
} = element;
// Calculate timing
const sourceStartSample = Math.floor(trimStart * buffer.sampleRate);
const sourceDuration = elementDuration - trimStart - trimEnd;
const sourceLengthSamples = Math.floor(sourceDuration * buffer.sampleRate);
const outputStartSample = Math.floor(startTime * sampleRate);
// Resample if needed (simple approach)
const resampleRatio = sampleRate / buffer.sampleRate;
const resampledLength = Math.floor(sourceLengthSamples * resampleRatio);
// Mix each channel
for (let channel = 0; channel < outputChannels; channel++) {
const outputData = outputBuffer.getChannelData(channel);
const sourceChannel = Math.min(channel, buffer.numberOfChannels - 1);
const sourceData = buffer.getChannelData(sourceChannel);
for (let i = 0; i < resampledLength; i++) {
const outputIndex = outputStartSample + i;
if (outputIndex >= outputLength) break;
// Simple resampling (could be improved with proper interpolation)
const sourceIndex = sourceStartSample + Math.floor(i / resampleRatio);
if (sourceIndex >= sourceData.length) break;
outputData[outputIndex] += sourceData[sourceIndex];
}
}
}
return outputBuffer;
}
export async function exportProject( export async function exportProject(
options: ExportOptions options: ExportOptions
): Promise<ExportResult> { ): Promise<ExportResult> {
const { format, quality, fps, onProgress, onCancel } = options; const { format, quality, fps, includeAudio, onProgress, onCancel } = options;
try { try {
const timelineStore = useTimelineStore.getState(); const timelineStore = useTimelineStore.getState();
@@ -81,9 +197,38 @@ export async function exportProject(
output.addVideoTrack(videoSource, { frameRate: exportFps }); output.addVideoTrack(videoSource, { frameRate: exportFps });
// Start the output // Add audio track if requested (but don't add data yet)
let audioSource: AudioBufferSource | null = null;
let audioBuffer: AudioBuffer | null = null;
if (includeAudio) {
onProgress?.(0.05); // 5% for audio processing
audioBuffer = await createTimelineAudioBuffer(
tracks,
mediaFiles,
duration
);
if (audioBuffer) {
audioSource = new AudioBufferSource({
codec: format === "webm" ? "opus" : "aac", // Opus for WebM, AAC for MP4
bitrate: qualityMap[quality], // Use same quality for audio
});
output.addAudioTrack(audioSource);
}
}
// Start the output (after all tracks are added)
await output.start(); await output.start();
// Now add audio data after starting
if (audioSource && audioBuffer) {
await audioSource.add(audioBuffer);
audioSource.close();
}
const totalFrames = Math.ceil(duration * exportFps); const totalFrames = Math.ceil(duration * exportFps);
let cancelled = false; let cancelled = false;
@@ -114,7 +259,11 @@ export async function exportProject(
const frameDuration = 1 / exportFps; const frameDuration = 1 / exportFps;
await videoSource.add(time, frameDuration); await videoSource.add(time, frameDuration);
onProgress?.(frameIndex / totalFrames); // Adjust progress to account for audio processing (5% at start)
const videoProgress = includeAudio
? 0.05 + (frameIndex / totalFrames) * 0.95
: frameIndex / totalFrames;
onProgress?.(videoProgress);
} }
if (cancelled) { if (cancelled) {
+1
View File
@@ -5,6 +5,7 @@ export interface ExportOptions {
format: ExportFormat; format: ExportFormat;
quality: ExportQuality; quality: ExportQuality;
fps?: number; fps?: number;
includeAudio?: boolean;
onProgress?: (progress: number) => void; onProgress?: (progress: number) => void;
onCancel?: () => boolean; onCancel?: () => boolean;
} }