mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: add audio support for export
This commit is contained in:
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user