mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: masks, properties refactor, shaders, storage migrations, and more
This commit is contained in:
+217
-32
@@ -1,24 +1,44 @@
|
||||
import type {
|
||||
AudioElement,
|
||||
LibraryAudioElement,
|
||||
TimelineElement,
|
||||
RetimeConfig,
|
||||
TimelineTrack,
|
||||
} from "@/types/timeline";
|
||||
import type { MediaAsset } from "@/types/assets";
|
||||
} from "@/lib/timeline";
|
||||
import { shouldMaintainPitch } from "@/constants/retime-constants";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { applyAudioMasteringToBuffer } from "@/lib/media/audio-mastering";
|
||||
import type { AudioCapableElement } from "@/lib/timeline/audio-state";
|
||||
import {
|
||||
hasAnimatedVolume,
|
||||
resolveEffectiveAudioGain,
|
||||
} from "@/lib/timeline/audio-state";
|
||||
import { canElementHaveAudio } from "@/lib/timeline/element-utils";
|
||||
import { canTracktHaveAudio } from "@/lib/timeline";
|
||||
import { mediaSupportsAudio } from "@/lib/media/media-utils";
|
||||
import { getSourceTimeAtClipTime, renderRetimedBuffer } from "@/lib/retime";
|
||||
import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny";
|
||||
|
||||
const MAX_AUDIO_CHANNELS = 2;
|
||||
const EXPORT_SAMPLE_RATE = 44100;
|
||||
const COARSE_SAMPLE_COUNT = 2048;
|
||||
|
||||
export type CollectedAudioElement = Omit<
|
||||
AudioElement,
|
||||
"type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl"
|
||||
> & { buffer: AudioBuffer };
|
||||
export interface CollectedAudioElement {
|
||||
timelineElement: AudioCapableElement;
|
||||
buffer: AudioBuffer;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
retime?: RetimeConfig;
|
||||
}
|
||||
|
||||
export function createAudioContext({ sampleRate }: { sampleRate?: number } = {}): AudioContext {
|
||||
export function createAudioContext({
|
||||
sampleRate,
|
||||
}: {
|
||||
sampleRate?: number;
|
||||
} = {}): AudioContext {
|
||||
const AudioContextConstructor =
|
||||
window.AudioContext ||
|
||||
(window as typeof window & { webkitAudioContext?: typeof AudioContext })
|
||||
@@ -90,13 +110,21 @@ export async function collectAudioElements({
|
||||
audioContext,
|
||||
}).then((audioBuffer) => {
|
||||
if (!audioBuffer) return null;
|
||||
const muted = element.muted === true || isTrackMuted;
|
||||
return {
|
||||
timelineElement: element,
|
||||
buffer: audioBuffer,
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
muted: element.muted || isTrackMuted,
|
||||
volume: resolveEffectiveAudioGain({
|
||||
element,
|
||||
trackMuted: isTrackMuted,
|
||||
localTime: 0,
|
||||
}),
|
||||
muted,
|
||||
retime: element.retime,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -113,14 +141,21 @@ export async function collectAudioElements({
|
||||
audioContext,
|
||||
}).then((audioBuffer) => {
|
||||
if (!audioBuffer) return null;
|
||||
const elementMuted = element.muted ?? false;
|
||||
const muted = (element.muted ?? false) || isTrackMuted;
|
||||
return {
|
||||
timelineElement: element,
|
||||
buffer: audioBuffer,
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
muted: elementMuted || isTrackMuted,
|
||||
volume: resolveEffectiveAudioGain({
|
||||
element,
|
||||
trackMuted: isTrackMuted,
|
||||
localTime: 0,
|
||||
}),
|
||||
muted,
|
||||
retime: element.retime,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -199,7 +234,10 @@ async function resolveAudioBufferForVideoElement({
|
||||
if (chunks.length === 0) return null;
|
||||
|
||||
const nativeSampleRate = chunks[0].sampleRate;
|
||||
const numChannels = Math.min(MAX_AUDIO_CHANNELS, chunks[0].numberOfChannels);
|
||||
const numChannels = Math.min(
|
||||
MAX_AUDIO_CHANNELS,
|
||||
chunks[0].numberOfChannels,
|
||||
);
|
||||
|
||||
const nativeChannels = Array.from(
|
||||
{ length: numChannels },
|
||||
@@ -208,17 +246,29 @@ async function resolveAudioBufferForVideoElement({
|
||||
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));
|
||||
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 outputSamples = Math.ceil(
|
||||
totalSamples * (targetSampleRate / nativeSampleRate),
|
||||
);
|
||||
const offlineContext = new OfflineAudioContext(
|
||||
numChannels,
|
||||
outputSamples,
|
||||
targetSampleRate,
|
||||
);
|
||||
|
||||
const nativeBuffer = audioContext.createBuffer(numChannels, totalSamples, nativeSampleRate);
|
||||
const nativeBuffer = audioContext.createBuffer(
|
||||
numChannels,
|
||||
totalSamples,
|
||||
nativeSampleRate,
|
||||
);
|
||||
for (let ch = 0; ch < numChannels; ch++) {
|
||||
nativeBuffer.copyToChannel(nativeChannels[ch], ch);
|
||||
}
|
||||
@@ -238,14 +288,18 @@ async function resolveAudioBufferForVideoElement({
|
||||
}
|
||||
|
||||
interface AudioMixSource {
|
||||
timelineElement: AudioCapableElement;
|
||||
file: File;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
volume: number;
|
||||
retime?: RetimeConfig;
|
||||
}
|
||||
|
||||
export interface AudioClipSource {
|
||||
timelineElement: AudioCapableElement;
|
||||
id: string;
|
||||
sourceKey: string;
|
||||
file: File;
|
||||
@@ -253,13 +307,17 @@ export interface AudioClipSource {
|
||||
duration: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
retime?: RetimeConfig;
|
||||
}
|
||||
|
||||
async function fetchLibraryAudioSource({
|
||||
element,
|
||||
volume,
|
||||
}: {
|
||||
element: LibraryAudioElement;
|
||||
volume: number;
|
||||
}): Promise<AudioMixSource | null> {
|
||||
try {
|
||||
const response = await fetch(element.sourceUrl);
|
||||
@@ -273,11 +331,14 @@ async function fetchLibraryAudioSource({
|
||||
});
|
||||
|
||||
return {
|
||||
timelineElement: element,
|
||||
file,
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
volume,
|
||||
retime: element.retime,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch library audio:", error);
|
||||
@@ -288,9 +349,11 @@ async function fetchLibraryAudioSource({
|
||||
async function fetchLibraryAudioClip({
|
||||
element,
|
||||
muted,
|
||||
volume,
|
||||
}: {
|
||||
element: LibraryAudioElement;
|
||||
muted: boolean;
|
||||
volume: number;
|
||||
}): Promise<AudioClipSource | null> {
|
||||
try {
|
||||
const response = await fetch(element.sourceUrl);
|
||||
@@ -304,6 +367,7 @@ async function fetchLibraryAudioClip({
|
||||
});
|
||||
|
||||
return {
|
||||
timelineElement: element,
|
||||
id: element.id,
|
||||
sourceKey: element.id,
|
||||
file,
|
||||
@@ -311,7 +375,9 @@ async function fetchLibraryAudioClip({
|
||||
duration: element.duration,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
volume,
|
||||
muted,
|
||||
retime: element.retime,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch library audio:", error);
|
||||
@@ -322,16 +388,21 @@ async function fetchLibraryAudioClip({
|
||||
function collectMediaAudioSource({
|
||||
element,
|
||||
mediaAsset,
|
||||
volume,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
element: AudioCapableElement;
|
||||
mediaAsset: MediaAsset;
|
||||
volume: number;
|
||||
}): AudioMixSource {
|
||||
return {
|
||||
timelineElement: element,
|
||||
file: mediaAsset.file,
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
volume,
|
||||
retime: element.retime,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -339,12 +410,15 @@ function collectMediaAudioClip({
|
||||
element,
|
||||
mediaAsset,
|
||||
muted,
|
||||
volume,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
element: AudioCapableElement;
|
||||
mediaAsset: MediaAsset;
|
||||
muted: boolean;
|
||||
volume: number;
|
||||
}): AudioClipSource {
|
||||
return {
|
||||
timelineElement: element,
|
||||
id: element.id,
|
||||
sourceKey: mediaAsset.id,
|
||||
file: mediaAsset.file,
|
||||
@@ -352,7 +426,9 @@ function collectMediaAudioClip({
|
||||
duration: element.duration,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
volume,
|
||||
muted,
|
||||
retime: element.retime,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -374,6 +450,11 @@ export async function collectAudioMixSources({
|
||||
|
||||
for (const element of track.elements) {
|
||||
if (!canElementHaveAudio(element)) continue;
|
||||
if (element.muted === true) continue;
|
||||
const volume = resolveEffectiveAudioGain({
|
||||
element,
|
||||
localTime: 0,
|
||||
});
|
||||
|
||||
if (element.type === "audio") {
|
||||
if (element.sourceType === "upload") {
|
||||
@@ -381,10 +462,12 @@ export async function collectAudioMixSources({
|
||||
if (!mediaAsset) continue;
|
||||
|
||||
audioMixSources.push(
|
||||
collectMediaAudioSource({ element, mediaAsset }),
|
||||
collectMediaAudioSource({ element, mediaAsset, volume }),
|
||||
);
|
||||
} else {
|
||||
pendingLibrarySources.push(fetchLibraryAudioSource({ element }));
|
||||
pendingLibrarySources.push(
|
||||
fetchLibraryAudioSource({ element, volume }),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -395,7 +478,7 @@ export async function collectAudioMixSources({
|
||||
|
||||
if (mediaSupportsAudio({ media: mediaAsset })) {
|
||||
audioMixSources.push(
|
||||
collectMediaAudioSource({ element, mediaAsset }),
|
||||
collectMediaAudioSource({ element, mediaAsset, volume }),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -432,6 +515,11 @@ export async function collectAudioClips({
|
||||
const isElementMuted =
|
||||
"muted" in element ? (element.muted ?? false) : false;
|
||||
const muted = isTrackMuted || isElementMuted;
|
||||
const volume = resolveEffectiveAudioGain({
|
||||
element,
|
||||
trackMuted: isTrackMuted,
|
||||
localTime: 0,
|
||||
});
|
||||
|
||||
if (element.type === "audio") {
|
||||
if (element.sourceType === "upload") {
|
||||
@@ -443,10 +531,13 @@ export async function collectAudioClips({
|
||||
element,
|
||||
mediaAsset,
|
||||
muted,
|
||||
volume,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
pendingLibraryClips.push(fetchLibraryAudioClip({ element, muted }));
|
||||
pendingLibraryClips.push(
|
||||
fetchLibraryAudioClip({ element, muted, volume }),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -461,6 +552,7 @@ export async function collectAudioClips({
|
||||
element,
|
||||
mediaAsset,
|
||||
muted,
|
||||
volume,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -510,36 +602,114 @@ export async function createTimelineAudioBuffer({
|
||||
for (const element of audioElements) {
|
||||
if (element.muted) continue;
|
||||
|
||||
const renderedBuffer = shouldMaintainPitch({
|
||||
rate: element.retime?.rate ?? 1,
|
||||
maintainPitch: element.retime?.maintainPitch,
|
||||
})
|
||||
? await renderRetimedBuffer({
|
||||
audioContext: context,
|
||||
sourceBuffer: element.buffer,
|
||||
trimStart: element.trimStart,
|
||||
clipDuration: element.duration,
|
||||
retime: element.retime,
|
||||
maintainPitch: true,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
mixAudioChannels({
|
||||
element,
|
||||
buffer: renderedBuffer ?? element.buffer,
|
||||
trimStart: renderedBuffer ? 0 : element.trimStart,
|
||||
retime: renderedBuffer ? undefined : element.retime,
|
||||
outputBuffer,
|
||||
outputLength,
|
||||
sampleRate,
|
||||
});
|
||||
}
|
||||
|
||||
return outputBuffer;
|
||||
return await applyAudioMasteringToBuffer({ audioBuffer: outputBuffer });
|
||||
}
|
||||
|
||||
export function computeGlobalMaxRms({
|
||||
buffer,
|
||||
}: {
|
||||
buffer: AudioBuffer;
|
||||
}): number {
|
||||
const channels = buffer.numberOfChannels;
|
||||
const step = Math.max(1, Math.floor(buffer.length / COARSE_SAMPLE_COUNT));
|
||||
let globalMax = 0;
|
||||
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const data = buffer.getChannelData(c);
|
||||
for (let i = 0; i + step <= buffer.length; i += step) {
|
||||
for (let j = i; j < i + step; j++) {
|
||||
const abs = Math.abs(data[j]);
|
||||
if (abs > globalMax) globalMax = abs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return globalMax || 1;
|
||||
}
|
||||
|
||||
export function extractRmsRange({
|
||||
buffer,
|
||||
count,
|
||||
startSample,
|
||||
endSample,
|
||||
globalMax,
|
||||
}: {
|
||||
buffer: AudioBuffer;
|
||||
count: number;
|
||||
startSample: number;
|
||||
endSample: number;
|
||||
globalMax: number;
|
||||
}): number[] {
|
||||
const channels = buffer.numberOfChannels;
|
||||
const rangeLength = endSample - startSample;
|
||||
const step = Math.max(1, Math.floor(rangeLength / count));
|
||||
const peaks = new Float32Array(count);
|
||||
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const data = buffer.getChannelData(c);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const start = startSample + i * step;
|
||||
const end = Math.min(start + step, endSample);
|
||||
for (let j = start; j < end; j++) {
|
||||
const abs = Math.abs(data[j]);
|
||||
if (abs > peaks[i]) peaks[i] = abs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const norm = 1 / globalMax;
|
||||
const result = new Array<number>(count);
|
||||
for (let i = 0; i < count; i++) result[i] = Math.min(1, peaks[i] * norm);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function mixAudioChannels({
|
||||
element,
|
||||
buffer,
|
||||
trimStart,
|
||||
retime,
|
||||
outputBuffer,
|
||||
outputLength,
|
||||
sampleRate,
|
||||
}: {
|
||||
element: CollectedAudioElement;
|
||||
buffer: AudioBuffer;
|
||||
trimStart: number;
|
||||
retime?: RetimeConfig;
|
||||
outputBuffer: AudioBuffer;
|
||||
outputLength: number;
|
||||
sampleRate: number;
|
||||
}): void {
|
||||
const { buffer, startTime, trimStart, duration: elementDuration } = element;
|
||||
const { startTime, duration: elementDuration } = element;
|
||||
|
||||
const sourceStartSample = Math.floor(trimStart * buffer.sampleRate);
|
||||
const sourceLengthSamples = Math.floor(elementDuration * buffer.sampleRate);
|
||||
const outputStartSample = Math.floor(startTime * sampleRate);
|
||||
|
||||
const resampleRatio = sampleRate / buffer.sampleRate;
|
||||
const resampledLength = Math.floor(sourceLengthSamples * resampleRatio);
|
||||
const renderedLength = Math.ceil(elementDuration * sampleRate);
|
||||
|
||||
const outputChannels = 2;
|
||||
for (let channel = 0; channel < outputChannels; channel++) {
|
||||
@@ -547,14 +717,29 @@ function mixAudioChannels({
|
||||
const sourceChannel = Math.min(channel, buffer.numberOfChannels - 1);
|
||||
const sourceData = buffer.getChannelData(sourceChannel);
|
||||
|
||||
for (let i = 0; i < resampledLength; i++) {
|
||||
for (let i = 0; i < renderedLength; i++) {
|
||||
const outputIndex = outputStartSample + i;
|
||||
if (outputIndex >= outputLength) break;
|
||||
|
||||
const sourceIndex = sourceStartSample + Math.floor(i / resampleRatio);
|
||||
const clipTime = i / sampleRate;
|
||||
const sourceTime =
|
||||
trimStart + getSourceTimeAtClipTime({ clipTime, retime });
|
||||
const sourceIndex = sourceTime * buffer.sampleRate;
|
||||
if (sourceIndex >= sourceData.length) break;
|
||||
|
||||
outputData[outputIndex] += sourceData[sourceIndex];
|
||||
const lowerIndex = Math.floor(sourceIndex);
|
||||
const upperIndex = Math.min(sourceData.length - 1, lowerIndex + 1);
|
||||
const fraction = sourceIndex - lowerIndex;
|
||||
const gain = hasAnimatedVolume({ element: element.timelineElement })
|
||||
? resolveEffectiveAudioGain({
|
||||
element: element.timelineElement,
|
||||
localTime: clipTime,
|
||||
})
|
||||
: element.volume;
|
||||
outputData[outputIndex] +=
|
||||
(sourceData[lowerIndex] * (1 - fraction) +
|
||||
sourceData[upperIndex] * fraction) *
|
||||
gain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user