summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/media
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/lib/media
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/lib/media')
-rw-r--r--apps/web/src/lib/media/audio-mastering.ts103
-rw-r--r--apps/web/src/lib/media/audio.ts868
-rw-r--r--apps/web/src/lib/media/media-utils.ts32
-rw-r--r--apps/web/src/lib/media/mediabunny.ts168
-rw-r--r--apps/web/src/lib/media/processing.ts296
-rw-r--r--apps/web/src/lib/media/types.ts9
-rw-r--r--apps/web/src/lib/media/upload-toast.ts51
-rw-r--r--apps/web/src/lib/media/waveform-summary.ts233
8 files changed, 0 insertions, 1760 deletions
diff --git a/apps/web/src/lib/media/audio-mastering.ts b/apps/web/src/lib/media/audio-mastering.ts
deleted file mode 100644
index f901c3bf..00000000
--- a/apps/web/src/lib/media/audio-mastering.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-const MASTER_LIMITER_THRESHOLD_DB = -1;
-const MASTER_LIMITER_KNEE_DB = 0;
-const MASTER_LIMITER_RATIO = 20;
-const MASTER_LIMITER_ATTACK_SECONDS = 0.001;
-const MASTER_LIMITER_RELEASE_SECONDS = 0.12;
-const MASTER_OUTPUT_HEADROOM = 0.98;
-
-export function getAudioBufferPeak({
- audioBuffer,
-}: {
- audioBuffer: AudioBuffer;
-}): number {
- let peak = 0;
-
- for (let channel = 0; channel < audioBuffer.numberOfChannels; channel++) {
- const channelData = audioBuffer.getChannelData(channel);
- for (let index = 0; index < channelData.length; index++) {
- const magnitude = Math.abs(channelData[index]);
- if (magnitude > peak) {
- peak = magnitude;
- }
- }
- }
-
- return peak;
-}
-
-export function createAudioMasteringChain({
- audioContext,
- destination,
-}: {
- audioContext: AudioContext | OfflineAudioContext;
- destination: AudioNode;
-}): {
- input: GainNode;
-} {
- const input = audioContext.createGain();
- const limiter = audioContext.createDynamicsCompressor();
- const outputGain = audioContext.createGain();
-
- limiter.threshold.value = MASTER_LIMITER_THRESHOLD_DB;
- limiter.knee.value = MASTER_LIMITER_KNEE_DB;
- limiter.ratio.value = MASTER_LIMITER_RATIO;
- limiter.attack.value = MASTER_LIMITER_ATTACK_SECONDS;
- limiter.release.value = MASTER_LIMITER_RELEASE_SECONDS;
- outputGain.gain.value = MASTER_OUTPUT_HEADROOM;
-
- input.connect(limiter);
- limiter.connect(outputGain);
- outputGain.connect(destination);
-
- return { input };
-}
-
-export async function applyAudioMasteringToBuffer({
- audioBuffer,
-}: {
- audioBuffer: AudioBuffer;
-}): Promise<AudioBuffer> {
- if (getAudioBufferPeak({ audioBuffer }) <= MASTER_OUTPUT_HEADROOM) {
- return audioBuffer;
- }
-
- const offlineContext = new OfflineAudioContext(
- audioBuffer.numberOfChannels,
- Math.max(1, audioBuffer.length),
- audioBuffer.sampleRate,
- );
- const source = offlineContext.createBufferSource();
- source.buffer = audioBuffer;
-
- const { input } = createAudioMasteringChain({
- audioContext: offlineContext,
- destination: offlineContext.destination,
- });
- source.connect(input);
- source.start(0);
-
- const renderedBuffer = await offlineContext.startRendering();
- clampAudioBufferPeak({
- audioBuffer: renderedBuffer,
- maxPeak: MASTER_OUTPUT_HEADROOM,
- });
- return renderedBuffer;
-}
-
-function clampAudioBufferPeak({
- audioBuffer,
- maxPeak,
-}: {
- audioBuffer: AudioBuffer;
- maxPeak: number;
-}): void {
- for (let channel = 0; channel < audioBuffer.numberOfChannels; channel++) {
- const channelData = audioBuffer.getChannelData(channel);
- for (let index = 0; index < channelData.length; index++) {
- channelData[index] = Math.max(
- -maxPeak,
- Math.min(maxPeak, channelData[index]),
- );
- }
- }
-}
diff --git a/apps/web/src/lib/media/audio.ts b/apps/web/src/lib/media/audio.ts
deleted file mode 100644
index 675cc3a5..00000000
--- a/apps/web/src/lib/media/audio.ts
+++ /dev/null
@@ -1,868 +0,0 @@
-import type {
- AudioElement,
- VideoElement,
- LibraryAudioElement,
- RetimeConfig,
- SceneTracks,
-} from "@/lib/timeline";
-import { shouldMaintainPitch } from "@/lib/retime/rate";
-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 { doesElementHaveEnabledAudio } from "@/lib/timeline/audio-separation";
-import { canElementHaveAudio, hasMediaId } from "@/lib/timeline/element-utils";
-import { canTrackHaveAudio } from "@/lib/timeline";
-import { mediaSupportsAudio } from "@/lib/media/media-utils";
-import { getSourceTimeAtClipTime, renderRetimedBuffer } from "@/lib/retime";
-import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-import {
- computeRmsBuckets,
- type SampleBucket,
-} from "@/lib/media/waveform-summary";
-
-const MAX_AUDIO_CHANNELS = 2;
-const EXPORT_SAMPLE_RATE = 44100;
-
-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 {
- const AudioContextConstructor =
- window.AudioContext ||
- (window as typeof window & { webkitAudioContext?: typeof AudioContext })
- .webkitAudioContext;
-
- return new AudioContextConstructor(sampleRate ? { sampleRate } : undefined);
-}
-
-export interface DecodedAudio {
- samples: Float32Array;
- sampleRate: number;
-}
-
-export async function decodeAudioToFloat32({
- audioBlob,
- sampleRate,
-}: {
- audioBlob: Blob;
- sampleRate?: number;
-}): Promise<DecodedAudio> {
- const audioContext = createAudioContext({ sampleRate });
- const arrayBuffer = await audioBlob.arrayBuffer();
- const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
-
- // mix down to mono
- const numChannels = audioBuffer.numberOfChannels;
- const length = audioBuffer.length;
- const samples = new Float32Array(length);
-
- for (let i = 0; i < length; i++) {
- let sum = 0;
- for (let channel = 0; channel < numChannels; channel++) {
- sum += audioBuffer.getChannelData(channel)[i];
- }
- samples[i] = sum / numChannels;
- }
-
- return { samples, sampleRate: audioBuffer.sampleRate };
-}
-
-export interface AudibleElementCandidate {
- element: AudioElement | VideoElement;
- mediaAsset: MediaAsset | null;
-}
-
-export function collectAudibleCandidates({
- tracks,
- mediaAssets,
-}: {
- tracks: SceneTracks;
- mediaAssets: MediaAsset[];
-}): AudibleElementCandidate[] {
- const allTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
- const mediaMap = new Map(mediaAssets.map((a) => [a.id, a]));
- const candidates: AudibleElementCandidate[] = [];
-
- for (const track of allTracks) {
- if (canTrackHaveAudio(track) && track.muted) continue;
-
- for (const element of track.elements) {
- if (!canElementHaveAudio(element)) continue;
- if (element.duration <= 0) continue;
-
- const mediaAsset = hasMediaId(element)
- ? (mediaMap.get(element.mediaId) ?? null)
- : null;
- if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue;
-
- candidates.push({ element, mediaAsset });
- }
- }
-
- return candidates;
-}
-
-export function timelineHasAudio({
- tracks,
- mediaAssets,
-}: {
- tracks: SceneTracks;
- mediaAssets: MediaAsset[];
-}): boolean {
- return collectAudibleCandidates({ tracks, mediaAssets }).some(
- ({ element }) => element.muted !== true,
- );
-}
-
-export async function collectAudioElements({
- tracks,
- mediaAssets,
- audioContext,
-}: {
- tracks: SceneTracks;
- mediaAssets: MediaAsset[];
- audioContext: AudioContext;
-}): Promise<CollectedAudioElement[]> {
- const candidates = collectAudibleCandidates({ tracks, mediaAssets });
- const mediaMap = new Map<string, MediaAsset>(
- mediaAssets.map((media) => [media.id, media]),
- );
- const pendingElements: Array<Promise<CollectedAudioElement | null>> = [];
-
- for (const { element, mediaAsset } of candidates) {
- if (element.type === "audio") {
- pendingElements.push(
- resolveAudioBufferForElement({
- element,
- mediaMap,
- audioContext,
- }).then((audioBuffer) => {
- if (!audioBuffer) return null;
- return {
- timelineElement: element,
- buffer: audioBuffer,
- startTime: element.startTime / TICKS_PER_SECOND,
- duration: element.duration / TICKS_PER_SECOND,
- trimStart: element.trimStart / TICKS_PER_SECOND,
- trimEnd: element.trimEnd / TICKS_PER_SECOND,
- volume: resolveEffectiveAudioGain({
- element,
- trackMuted: false,
- localTime: 0,
- }),
- muted: element.muted === true,
- retime: element.retime,
- };
- }),
- );
- continue;
- }
-
- if (element.type === "video") {
- if (!mediaAsset || !mediaSupportsAudio({ media: mediaAsset })) continue;
-
- pendingElements.push(
- resolveAudioBufferForAsset({
- asset: mediaAsset,
- audioContext,
- }).then((audioBuffer) => {
- if (!audioBuffer) return null;
- return {
- timelineElement: element,
- buffer: audioBuffer,
- startTime: element.startTime / TICKS_PER_SECOND,
- duration: element.duration / TICKS_PER_SECOND,
- trimStart: element.trimStart / TICKS_PER_SECOND,
- trimEnd: element.trimEnd / TICKS_PER_SECOND,
- volume: resolveEffectiveAudioGain({
- element,
- trackMuted: false,
- localTime: 0,
- }),
- muted: element.muted ?? false,
- retime: element.retime,
- };
- }),
- );
- }
- }
-
- const resolvedElements = await Promise.all(pendingElements);
- const audioElements: CollectedAudioElement[] = [];
- for (const element of resolvedElements) {
- if (element) audioElements.push(element);
- }
- return audioElements;
-}
-
-async function resolveAudioBufferForElement({
- element,
- mediaMap,
- audioContext,
-}: {
- element: AudioElement;
- mediaMap: Map<string, MediaAsset>;
- audioContext: AudioContext;
-}): Promise<AudioBuffer | null> {
- try {
- if (element.sourceType === "upload") {
- const asset = mediaMap.get(element.mediaId);
- if (!asset) return null;
- return await resolveAudioBufferForAsset({ asset, audioContext });
- }
-
- if (element.buffer) return element.buffer;
-
- const response = await fetch(element.sourceUrl);
- if (!response.ok) {
- throw new Error(`Library audio fetch failed: ${response.status}`);
- }
-
- const arrayBuffer = await response.arrayBuffer();
- return await audioContext.decodeAudioData(arrayBuffer.slice(0));
- } catch (error) {
- console.warn("Failed to decode audio:", error);
- return null;
- }
-}
-
-async function resolveAudioBufferForAsset({
- asset,
- audioContext,
-}: {
- asset: MediaAsset;
- audioContext: AudioContext;
-}): Promise<AudioBuffer | null> {
- if (asset.type === "audio") {
- try {
- const arrayBuffer = await asset.file.arrayBuffer();
- return await audioContext.decodeAudioData(arrayBuffer.slice(0));
- } catch (error) {
- console.warn("Failed to decode audio asset:", error);
- return null;
- }
- }
-
- const input = new Input({
- source: new BlobSource(asset.file),
- formats: ALL_FORMATS,
- });
-
- try {
- const audioTrack = await input.getPrimaryAudioTrack();
- 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) {
- console.warn("Failed to decode asset audio:", error);
- return null;
- } finally {
- input.dispose();
- }
-}
-
-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;
- startTime: number;
- 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);
- if (!response.ok) {
- throw new Error(`Library audio fetch failed: ${response.status}`);
- }
-
- const blob = await response.blob();
- const file = new File([blob], `${element.name}.mp3`, {
- type: "audio/mpeg",
- });
-
- return {
- timelineElement: element,
- file,
- startTime: element.startTime / TICKS_PER_SECOND,
- duration: element.duration / TICKS_PER_SECOND,
- trimStart: element.trimStart / TICKS_PER_SECOND,
- trimEnd: element.trimEnd / TICKS_PER_SECOND,
- volume,
- retime: element.retime,
- };
- } catch (error) {
- console.warn("Failed to fetch library audio:", error);
- return null;
- }
-}
-
-async function fetchLibraryAudioClip({
- element,
- muted,
- volume,
-}: {
- element: LibraryAudioElement;
- muted: boolean;
- volume: number;
-}): Promise<AudioClipSource | null> {
- try {
- const response = await fetch(element.sourceUrl);
- if (!response.ok) {
- throw new Error(`Library audio fetch failed: ${response.status}`);
- }
-
- const blob = await response.blob();
- const file = new File([blob], `${element.name}.mp3`, {
- type: "audio/mpeg",
- });
-
- return {
- timelineElement: element,
- id: element.id,
- sourceKey: element.id,
- file,
- startTime: element.startTime,
- duration: element.duration,
- trimStart: element.trimStart,
- trimEnd: element.trimEnd,
- volume,
- muted,
- retime: element.retime,
- };
- } catch (error) {
- console.warn("Failed to fetch library audio:", error);
- return null;
- }
-}
-
-function collectMediaAudioSource({
- element,
- mediaAsset,
- volume,
-}: {
- element: AudioCapableElement;
- mediaAsset: MediaAsset;
- volume: number;
-}): AudioMixSource {
- return {
- timelineElement: element,
- file: mediaAsset.file,
- startTime: element.startTime / TICKS_PER_SECOND,
- duration: element.duration / TICKS_PER_SECOND,
- trimStart: element.trimStart / TICKS_PER_SECOND,
- trimEnd: element.trimEnd / TICKS_PER_SECOND,
- volume,
- retime: element.retime,
- };
-}
-
-function collectMediaAudioClip({
- element,
- mediaAsset,
- muted,
- volume,
-}: {
- element: AudioCapableElement;
- mediaAsset: MediaAsset;
- muted: boolean;
- volume: number;
-}): AudioClipSource {
- return {
- timelineElement: element,
- id: element.id,
- sourceKey: mediaAsset.id,
- file: mediaAsset.file,
- startTime: element.startTime / TICKS_PER_SECOND,
- duration: element.duration / TICKS_PER_SECOND,
- trimStart: element.trimStart / TICKS_PER_SECOND,
- trimEnd: element.trimEnd / TICKS_PER_SECOND,
- volume,
- muted,
- retime: element.retime,
- };
-}
-
-export async function collectAudioMixSources({
- tracks,
- mediaAssets,
-}: {
- tracks: SceneTracks;
- mediaAssets: MediaAsset[];
-}): Promise<AudioMixSource[]> {
- const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
- const audioMixSources: AudioMixSource[] = [];
- const mediaMap = new Map<string, MediaAsset>(
- mediaAssets.map((asset) => [asset.id, asset]),
- );
- const pendingLibrarySources: Array<Promise<AudioMixSource | null>> = [];
-
- for (const track of orderedTracks) {
- if (canTrackHaveAudio(track) && track.muted) continue;
-
- for (const element of track.elements) {
- if (!canElementHaveAudio(element)) continue;
- if (element.muted === true) continue;
- const mediaAsset = hasMediaId(element)
- ? (mediaMap.get(element.mediaId) ?? null)
- : null;
- if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue;
- const volume = resolveEffectiveAudioGain({
- element,
- localTime: 0,
- });
-
- if (element.type === "audio") {
- if (element.sourceType === "upload") {
- const mediaAsset = mediaMap.get(element.mediaId);
- if (!mediaAsset) continue;
-
- audioMixSources.push(
- collectMediaAudioSource({ element, mediaAsset, volume }),
- );
- } else {
- pendingLibrarySources.push(
- fetchLibraryAudioSource({ element, volume }),
- );
- }
- continue;
- }
-
- if (element.type === "video") {
- if (mediaAsset && mediaSupportsAudio({ media: mediaAsset })) {
- audioMixSources.push(
- collectMediaAudioSource({ element, mediaAsset, volume }),
- );
- }
- }
- }
- }
-
- const resolvedLibrarySources = await Promise.all(pendingLibrarySources);
- for (const source of resolvedLibrarySources) {
- if (source) audioMixSources.push(source);
- }
-
- return audioMixSources;
-}
-
-export async function collectAudioClips({
- tracks,
- mediaAssets,
-}: {
- tracks: SceneTracks;
- mediaAssets: MediaAsset[];
-}): Promise<AudioClipSource[]> {
- const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
- const clips: AudioClipSource[] = [];
- const mediaMap = new Map<string, MediaAsset>(
- mediaAssets.map((asset) => [asset.id, asset]),
- );
- const pendingLibraryClips: Array<Promise<AudioClipSource | null>> = [];
-
- for (const track of orderedTracks) {
- const isTrackMuted = canTrackHaveAudio(track) && track.muted;
-
- for (const element of track.elements) {
- if (!canElementHaveAudio(element)) continue;
-
- const mediaAsset = hasMediaId(element)
- ? (mediaMap.get(element.mediaId) ?? null)
- : null;
- if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue;
-
- 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") {
- const mediaAsset = mediaMap.get(element.mediaId);
- if (!mediaAsset) continue;
-
- clips.push(
- collectMediaAudioClip({
- element,
- mediaAsset,
- muted,
- volume,
- }),
- );
- } else {
- pendingLibraryClips.push(
- fetchLibraryAudioClip({ element, muted, volume }),
- );
- }
- continue;
- }
-
- if (element.type === "video") {
- if (mediaAsset && mediaSupportsAudio({ media: mediaAsset })) {
- clips.push(
- collectMediaAudioClip({
- element,
- mediaAsset,
- muted,
- volume,
- }),
- );
- }
- }
- }
- }
-
- const resolvedLibraryClips = await Promise.all(pendingLibraryClips);
- for (const clip of resolvedLibraryClips) {
- if (clip) clips.push(clip);
- }
-
- return clips;
-}
-
-export async function createTimelineAudioBuffer({
- tracks,
- mediaAssets,
- duration,
- sampleRate = EXPORT_SAMPLE_RATE,
- audioContext,
-}: {
- tracks: SceneTracks;
- mediaAssets: MediaAsset[];
- duration: number;
- sampleRate?: number;
- audioContext?: AudioContext;
-}): Promise<AudioBuffer | null> {
- const context = audioContext ?? createAudioContext({ sampleRate });
-
- const audioElements = await collectAudioElements({
- tracks,
- mediaAssets,
- audioContext: context,
- });
-
- if (audioElements.length === 0) return null;
-
- const outputChannels = 2;
- const durationSeconds = duration / TICKS_PER_SECOND;
- const outputLength = Math.ceil(durationSeconds * sampleRate);
- const outputBuffer = context.createBuffer(
- outputChannels,
- outputLength,
- sampleRate,
- );
-
- 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 await applyAudioMasteringToBuffer({ audioBuffer: outputBuffer });
-}
-
-function collectPeakRange({
- buffer,
- count,
- startSample,
- endSample,
-}: {
- buffer: AudioBuffer;
- count: number;
- startSample: number;
- endSample: number;
-}): Float32Array {
- const channels = buffer.numberOfChannels;
- 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 { bucketStart: start, bucketEnd: end } = getSampleBucketRange({
- startSample,
- endSample,
- bucketIndex: i,
- bucketCount: count,
- });
- for (let j = start; j < end; j++) {
- const abs = Math.abs(data[j]);
- if (abs > peaks[i]) peaks[i] = abs;
- }
- }
- }
-
- return peaks;
-}
-
-export function extractPeakRange({
- buffer,
- count,
- startSample,
- endSample,
-}: {
- buffer: AudioBuffer;
- count: number;
- startSample: number;
- endSample: number;
-}): number[] {
- return Array.from(
- collectPeakRange({
- buffer,
- count,
- startSample,
- endSample,
- }),
- );
-}
-
-export function getSampleBucketRange({
- startSample,
- endSample,
- bucketIndex,
- bucketCount,
-}: {
- startSample: number;
- endSample: number;
- bucketIndex: number;
- bucketCount: number;
-}): {
- bucketStart: number;
- bucketEnd: number;
-} {
- const rangeLength = Math.max(0, endSample - startSample);
- const bucketStart =
- startSample + Math.floor((bucketIndex * rangeLength) / bucketCount);
- const bucketEnd =
- startSample + Math.floor(((bucketIndex + 1) * rangeLength) / bucketCount);
- return {
- bucketStart,
- bucketEnd: Math.max(bucketStart, bucketEnd),
- };
-}
-
-export function extractRmsBuckets({
- buffer,
- buckets,
-}: {
- buffer: AudioBuffer;
- buckets: SampleBucket[];
-}): number[] {
- return computeRmsBuckets({ buffer, buckets });
-}
-
-/**
- * Computes per-bucket waveform amplitude using the maximum RMS over a short
- * analysis window inside each bucket.
- *
- * A naive mean-RMS over a whole bucket averages silence together with nearby
- * sound, which smears transitions (e.g. the onset of speech) across the
- * bucket and makes the waveform respond late. Taking the max over fixed
- * short windows (~20 ms) preserves the smooth, non-jittery RMS character
- * while making transitions land where they actually happen in the audio.
- *
- * Channels are combined per-window before taking the max, so the measure
- * reflects total energy regardless of stereo layout.
- */
-export function extractRmsRange({
- buffer,
- count,
- startSample,
- endSample,
-}: {
- buffer: AudioBuffer;
- count: number;
- startSample: number;
- endSample: number;
-}): number[] {
- return extractRmsBuckets({
- buffer,
- buckets: Array.from({ length: count }, (_, bucketIndex) =>
- getSampleBucketRange({
- startSample,
- endSample,
- bucketIndex,
- bucketCount: count,
- }),
- ),
- });
-}
-
-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 { startTime, duration: elementDuration } = element;
-
- const outputStartSample = Math.floor(startTime * sampleRate);
- const renderedLength = Math.ceil(elementDuration * sampleRate);
-
- const outputChannels = 2;
- 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 < renderedLength; i++) {
- const outputIndex = outputStartSample + i;
- if (outputIndex >= outputLength) break;
-
- const clipTime = i / sampleRate;
- const sourceTime =
- trimStart + getSourceTimeAtClipTime({ clipTime, retime });
- const sourceIndex = sourceTime * buffer.sampleRate;
- if (sourceIndex >= sourceData.length) break;
-
- 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;
- }
- }
-}
diff --git a/apps/web/src/lib/media/media-utils.ts b/apps/web/src/lib/media/media-utils.ts
deleted file mode 100644
index dc73da23..00000000
--- a/apps/web/src/lib/media/media-utils.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import type { MediaAsset, MediaType } from "@/lib/media/types";
-
-export const SUPPORTS_AUDIO: readonly MediaType[] = ["audio", "video"];
-
-export function mediaSupportsAudio({
- media,
-}: {
- media: MediaAsset | null | undefined;
-}): boolean {
- if (!media) return false;
- return SUPPORTS_AUDIO.includes(media.type);
-}
-
-export const getMediaTypeFromFile = ({
- file,
-}: {
- file: File;
-}): MediaType | null => {
- const { type } = file;
-
- if (type.startsWith("image/")) {
- return "image";
- }
- if (type.startsWith("video/")) {
- return "video";
- }
- if (type.startsWith("audio/")) {
- return "audio";
- }
-
- return null;
-};
diff --git a/apps/web/src/lib/media/mediabunny.ts b/apps/web/src/lib/media/mediabunny.ts
deleted file mode 100644
index 1e97fc42..00000000
--- a/apps/web/src/lib/media/mediabunny.ts
+++ /dev/null
@@ -1,168 +0,0 @@
-import { Input, ALL_FORMATS, BlobSource } from "mediabunny";
-import { createTimelineAudioBuffer } from "@/lib/media/audio";
-import type { SceneTracks } from "@/lib/timeline";
-import type { MediaAsset } from "@/lib/media/types";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-
-export async function getVideoInfo({
- videoFile,
-}: {
- videoFile: File;
-}): Promise<{
- duration: number;
- width: number;
- height: number;
- fps: number;
- hasAudio: boolean;
-}> {
- const input = new Input({
- source: new BlobSource(videoFile),
- formats: ALL_FORMATS,
- });
-
- const duration = await input.computeDuration();
- const videoTrack = await input.getPrimaryVideoTrack();
-
- if (!videoTrack) {
- throw new Error("No video track found in the file");
- }
-
- const packetStats = await videoTrack.computePacketStats(100);
- const fps = packetStats.averagePacketRate;
- const audioTrack = await input.getPrimaryAudioTrack();
-
- return {
- duration,
- width: videoTrack.displayWidth,
- height: videoTrack.displayHeight,
- fps,
- hasAudio: audioTrack !== null,
- };
-}
-
-const SAMPLE_RATE = 44100;
-const NUM_CHANNELS = 2;
-const EMPTY_TIMELINE_SILENT_DURATION_SECONDS = 0.1;
-const MIN_SILENT_DURATION_SECONDS = 0.001;
-
-export const extractTimelineAudio = async ({
- tracks,
- mediaAssets,
- totalDuration,
- onProgress,
-}: {
- tracks: SceneTracks;
- mediaAssets: MediaAsset[];
- totalDuration: number;
- onProgress?: (progress: number) => void;
-}): Promise<Blob> => {
- if (totalDuration === 0) {
- return createWavBlob({
- samples: new Float32Array(
- SAMPLE_RATE * EMPTY_TIMELINE_SILENT_DURATION_SECONDS,
- ),
- });
- }
-
- onProgress?.(10);
-
- const audioBuffer = await createTimelineAudioBuffer({
- tracks,
- mediaAssets,
- duration: totalDuration,
- sampleRate: SAMPLE_RATE,
- });
-
- if (!audioBuffer) {
- const silentDurationSeconds = Math.max(
- MIN_SILENT_DURATION_SECONDS,
- totalDuration / TICKS_PER_SECOND,
- );
- const silentSamples = new Float32Array(
- Math.ceil(silentDurationSeconds * SAMPLE_RATE) * NUM_CHANNELS,
- );
- return createWavBlob({ samples: silentSamples });
- }
-
- onProgress?.(90);
-
- const interleavedSamples = interleaveAudioBuffer({ audioBuffer });
- onProgress?.(100);
-
- return createWavBlob({ samples: interleavedSamples });
-};
-
-function interleaveAudioBuffer({
- audioBuffer,
-}: {
- audioBuffer: AudioBuffer;
-}): Float32Array {
- const numChannels = Math.min(NUM_CHANNELS, audioBuffer.numberOfChannels);
- const interleavedSamples = new Float32Array(
- audioBuffer.length * NUM_CHANNELS,
- );
-
- for (let sampleIndex = 0; sampleIndex < audioBuffer.length; sampleIndex++) {
- for (let channel = 0; channel < NUM_CHANNELS; channel++) {
- const sourceChannel = Math.min(channel, Math.max(0, numChannels - 1));
- interleavedSamples[sampleIndex * NUM_CHANNELS + channel] =
- audioBuffer.getChannelData(sourceChannel)[sampleIndex] ?? 0;
- }
- }
-
- return interleavedSamples;
-}
-
-function createWavBlob({ samples }: { samples: Float32Array }): Blob {
- const numChannels = NUM_CHANNELS;
- const bitsPerSample = 16;
- const bytesPerSample = bitsPerSample / 8;
- const numSamples = samples.length / numChannels;
- const dataSize = numSamples * numChannels * bytesPerSample;
- const buffer = new ArrayBuffer(44 + dataSize);
- const view = new DataView(buffer);
-
- // riff header
- writeString({ view, offset: 0, str: "RIFF" });
- view.setUint32(4, 36 + dataSize, true);
- writeString({ view, offset: 8, str: "WAVE" });
-
- // fmt chunk
- writeString({ view, offset: 12, str: "fmt " });
- view.setUint32(16, 16, true);
- view.setUint16(20, 1, true);
- view.setUint16(22, numChannels, true);
- view.setUint32(24, SAMPLE_RATE, true);
- view.setUint32(28, SAMPLE_RATE * numChannels * bytesPerSample, true);
- view.setUint16(32, numChannels * bytesPerSample, true);
- view.setUint16(34, bitsPerSample, true);
-
- // data chunk
- writeString({ view, offset: 36, str: "data" });
- view.setUint32(40, dataSize, true);
-
- // convert float32 to int16 and write
- let offset = 44;
- for (let i = 0; i < samples.length; i++) {
- const sample = Math.max(-1, Math.min(1, samples[i]));
- const int16 = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
- view.setInt16(offset, int16, true);
- offset += 2;
- }
-
- return new Blob([buffer], { type: "audio/wav" });
-}
-
-function writeString({
- view,
- offset,
- str,
-}: {
- view: DataView;
- offset: number;
- str: string;
-}): void {
- for (let i = 0; i < str.length; i++) {
- view.setUint8(offset + i, str.charCodeAt(i));
- }
-}
diff --git a/apps/web/src/lib/media/processing.ts b/apps/web/src/lib/media/processing.ts
deleted file mode 100644
index 547e7ed8..00000000
--- a/apps/web/src/lib/media/processing.ts
+++ /dev/null
@@ -1,296 +0,0 @@
-import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny";
-import { toast } from "sonner";
-import { getMediaTypeFromFile } from "@/lib/media/media-utils";
-import { formatStorageBytes } from "@/services/storage/quota";
-import { storageService } from "@/services/storage/service";
-import type { MediaAsset } from "@/lib/media/types";
-import { getVideoInfo } from "./mediabunny";
-
-export interface ProcessedMediaAsset extends Omit<MediaAsset, "id"> {}
-
-const THUMBNAIL_MAX_WIDTH = 1280;
-const THUMBNAIL_MAX_HEIGHT = 720;
-
-const getStorageLimitDescription = ({
- fileSize,
- availableBytes,
-}: {
- fileSize: number;
- availableBytes: number | null;
-}): string => {
- const fileSizeLabel = formatStorageBytes({ bytes: fileSize });
-
- if (availableBytes === null) {
- return `File size is ${fileSizeLabel}.`;
- }
-
- return `File size is ${fileSizeLabel}, but only ${formatStorageBytes({
- bytes: availableBytes,
- })} is safely available in browser storage.`;
-};
-
-const getThumbnailSize = ({
- width,
- height,
-}: {
- width: number;
- height: number;
-}): { width: number; height: number } => {
- const aspectRatio = width / height;
- let targetWidth = width;
- let targetHeight = height;
-
- if (targetWidth > THUMBNAIL_MAX_WIDTH) {
- targetWidth = THUMBNAIL_MAX_WIDTH;
- targetHeight = Math.round(targetWidth / aspectRatio);
- }
- if (targetHeight > THUMBNAIL_MAX_HEIGHT) {
- targetHeight = THUMBNAIL_MAX_HEIGHT;
- targetWidth = Math.round(targetHeight * aspectRatio);
- }
-
- return { width: targetWidth, height: targetHeight };
-};
-
-const renderToThumbnailDataUrl = ({
- width,
- height,
- draw,
-}: {
- width: number;
- height: number;
- draw: ({
- context,
- width,
- height,
- }: {
- context: CanvasRenderingContext2D;
- width: number;
- height: number;
- }) => void;
-}): string => {
- const size = getThumbnailSize({ width, height });
- const canvas = document.createElement("canvas");
- canvas.width = size.width;
- canvas.height = size.height;
- const context = canvas.getContext("2d");
-
- if (!context) {
- throw new Error("Could not get canvas context");
- }
-
- draw({ context, width: size.width, height: size.height });
- return canvas.toDataURL("image/jpeg", 0.8);
-};
-
-async function generateThumbnail({
- videoFile,
- timeInSeconds,
-}: {
- videoFile: File;
- timeInSeconds: number;
-}): Promise<string> {
- const input = new Input({
- source: new BlobSource(videoFile),
- formats: ALL_FORMATS,
- });
-
- const videoTrack = await input.getPrimaryVideoTrack();
- if (!videoTrack) {
- throw new Error("No video track found in the file");
- }
-
- const canDecode = await videoTrack.canDecode();
- if (!canDecode) {
- throw new Error("Video codec not supported for decoding");
- }
-
- const sink = new VideoSampleSink(videoTrack);
-
- const frame = await sink.getSample(timeInSeconds);
-
- if (!frame) {
- throw new Error("Could not get frame at specified time");
- }
-
- try {
- return renderToThumbnailDataUrl({
- width: videoTrack.displayWidth,
- height: videoTrack.displayHeight,
- draw: ({ context, width, height }) => {
- frame.draw(context, 0, 0, width, height);
- },
- });
- } finally {
- frame.close();
- }
-}
-
-async function generateImageThumbnail({
- imageFile,
-}: {
- imageFile: File;
-}): Promise<{ thumbnailUrl: string; width: number; height: number }> {
- return new Promise((resolve, reject) => {
- const image = new window.Image();
- const objectUrl = URL.createObjectURL(imageFile);
-
- image.addEventListener("load", () => {
- try {
- const thumbnailUrl = renderToThumbnailDataUrl({
- width: image.naturalWidth,
- height: image.naturalHeight,
- draw: ({ context, width, height }) => {
- context.drawImage(image, 0, 0, width, height);
- },
- });
- resolve({
- thumbnailUrl,
- width: image.naturalWidth,
- height: image.naturalHeight,
- });
- } catch (error) {
- reject(
- error instanceof Error ? error : new Error("Could not render image"),
- );
- } finally {
- URL.revokeObjectURL(objectUrl);
- image.remove();
- }
- });
-
- image.addEventListener("error", () => {
- URL.revokeObjectURL(objectUrl);
- image.remove();
- reject(new Error("Could not load image"));
- });
-
- image.src = objectUrl;
- });
-}
-
-export async function processMediaAssets({
- files,
- onProgress,
-}: {
- files: FileList | File[];
- onProgress?: ({ progress }: { progress: number }) => void;
-}): Promise<ProcessedMediaAsset[]> {
- const fileArray = Array.from(files);
- const processedAssets: ProcessedMediaAsset[] = [];
-
- const total = fileArray.length;
- let completed = 0;
-
- for (const file of fileArray) {
- const fileType = getMediaTypeFromFile({ file });
-
- if (!fileType) {
- toast.error(`Unsupported file type: ${file.name}`);
- continue;
- }
-
- const storageCheck = await storageService.canStoreFile({
- size: file.size,
- });
-
- if (!storageCheck.canStore) {
- toast.error(`Not enough browser storage for ${file.name}`, {
- description: getStorageLimitDescription({
- fileSize: file.size,
- availableBytes: storageCheck.availableBytes,
- }),
- });
- continue;
- }
-
- const url = URL.createObjectURL(file);
- let thumbnailUrl: string | undefined;
- let duration: number | undefined;
- let width: number | undefined;
- let height: number | undefined;
- let fps: number | undefined;
- let hasAudio: boolean | undefined;
-
- try {
- if (fileType === "image") {
- const result = await generateImageThumbnail({ imageFile: file });
- thumbnailUrl = result.thumbnailUrl;
- width = result.width;
- height = result.height;
- } else if (fileType === "video") {
- try {
- const videoInfo = await getVideoInfo({ videoFile: file });
- duration = videoInfo.duration;
- width = videoInfo.width;
- height = videoInfo.height;
- fps = Number.isFinite(videoInfo.fps)
- ? Math.round(videoInfo.fps)
- : undefined;
- hasAudio = videoInfo.hasAudio;
-
- thumbnailUrl = await generateThumbnail({
- videoFile: file,
- timeInSeconds: 1,
- });
- } catch (error) {
- console.warn("Video processing failed", error);
- }
- } else if (fileType === "audio") {
- // For audio, we don't set width/height/fps (they'll be undefined)
- duration = await getMediaDuration({ file });
- }
-
- processedAssets.push({
- name: file.name,
- type: fileType,
- file,
- url,
- thumbnailUrl,
- duration,
- width,
- height,
- fps,
- hasAudio,
- });
-
- await new Promise((resolve) => setTimeout(resolve, 0));
-
- completed += 1;
- if (onProgress) {
- const percent = Math.round((completed / total) * 100);
- onProgress({ progress: percent });
- }
- } catch (error) {
- console.error("Error processing file:", file.name, error);
- toast.error(`Failed to process ${file.name}`);
- URL.revokeObjectURL(url); // Clean up on error
- }
- }
-
- return processedAssets;
-}
-
-const getMediaDuration = ({ file }: { file: File }): Promise<number> => {
- return new Promise((resolve, reject) => {
- const element = document.createElement(
- file.type.startsWith("video/") ? "video" : "audio",
- ) as HTMLVideoElement;
- const objectUrl = URL.createObjectURL(file);
-
- element.addEventListener("loadedmetadata", () => {
- resolve(element.duration);
- URL.revokeObjectURL(objectUrl);
- element.remove();
- });
-
- element.addEventListener("error", () => {
- reject(new Error("Could not load media"));
- URL.revokeObjectURL(objectUrl);
- element.remove();
- });
-
- element.src = objectUrl;
- element.load();
- });
-};
diff --git a/apps/web/src/lib/media/types.ts b/apps/web/src/lib/media/types.ts
deleted file mode 100644
index 4d3e4106..00000000
--- a/apps/web/src/lib/media/types.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import type { MediaAssetData } from "@/services/storage/types";
-
-export type MediaType = "image" | "video" | "audio";
-
-export interface MediaAsset
- extends Omit<MediaAssetData, "size" | "lastModified"> {
- file: File;
- url?: string;
-}
diff --git a/apps/web/src/lib/media/upload-toast.ts b/apps/web/src/lib/media/upload-toast.ts
deleted file mode 100644
index 45846206..00000000
--- a/apps/web/src/lib/media/upload-toast.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { toast } from "sonner";
-
-export interface MediaUploadToastResult {
- uploadedCount: number;
- assetNames?: string[];
-}
-
-function getAssetLabel({ count }: { count: number }): string {
- return count === 1 ? "media asset" : "media assets";
-}
-
-function waitForNextPaint(): Promise<void> {
- return new Promise((resolve) => {
- requestAnimationFrame(() => {
- requestAnimationFrame(() => resolve());
- });
- });
-}
-
-export async function showMediaUploadToast<T extends MediaUploadToastResult>({
- filesCount,
- promise,
-}: {
- filesCount: number;
- promise: Promise<T> | (() => Promise<T>);
-}) {
- const run = typeof promise === "function" ? promise : () => promise;
- const toastPromise = toast.promise(async () => {
- await waitForNextPaint();
- return run();
- }, {
- loading: `Uploading ${getAssetLabel({ count: filesCount })}...`,
- success: ({ uploadedCount, assetNames }) => {
- if (uploadedCount === 1) {
- const assetName = assetNames?.[0];
- return assetName
- ? `${assetName} has been uploaded`
- : "1 media asset has been uploaded";
- }
-
- if (uploadedCount > 1) {
- return `${uploadedCount} media assets have been uploaded`;
- }
-
- return "No media assets were uploaded";
- },
- error: `Failed to upload ${getAssetLabel({ count: filesCount })}`,
- });
-
- return toastPromise.unwrap();
-}
diff --git a/apps/web/src/lib/media/waveform-summary.ts b/apps/web/src/lib/media/waveform-summary.ts
deleted file mode 100644
index 522d6e04..00000000
--- a/apps/web/src/lib/media/waveform-summary.ts
+++ /dev/null
@@ -1,233 +0,0 @@
-"use client";
-
-import { getSourceTimeAtClipTime } from "@/lib/retime";
-import type { RetimeConfig } from "@/lib/timeline";
-
-const RMS_ANALYSIS_WINDOW_SECONDS = 0.02;
-const DEFAULT_SOURCE_WAVEFORM_BUCKET_SIZE = 128;
-
-function computePeakBuckets({
- buffer,
- buckets,
-}: {
- buffer: AudioBuffer;
- buckets: SampleBucket[];
-}): number[] {
- const channels = buffer.numberOfChannels;
- const channelData: Float32Array[] = Array.from({ length: channels }, (_, c) =>
- buffer.getChannelData(c),
- );
-
- return buckets.map(({ bucketStart, bucketEnd }) => {
- let peak = 0;
- for (let c = 0; c < channels; c++) {
- const data = channelData[c];
- for (let j = bucketStart; j < bucketEnd; j++) {
- const abs = Math.abs(data[j] ?? 0);
- if (abs > peak) {
- peak = abs;
- }
- }
- }
- return peak;
- });
-}
-
-export interface SampleBucket {
- bucketStart: number;
- bucketEnd: number;
-}
-
-export interface SourceWaveformSummary {
- sourceKey: string;
- sampleRate: number;
- totalSamples: number;
- bucketSize: number;
- amplitudes: Float32Array;
-}
-
-export function buildWaveformSourceKey({
- kind,
- id,
-}: {
- kind: "media" | "library";
- id: string;
-}): string {
- return `${kind}:${id}`;
-}
-
-export function buildSourceWaveformSummary({
- sourceKey,
- buffer,
- bucketSize = DEFAULT_SOURCE_WAVEFORM_BUCKET_SIZE,
-}: {
- sourceKey: string;
- buffer: AudioBuffer;
- bucketSize?: number;
-}): SourceWaveformSummary {
- const safeBucketSize = Math.max(1, Math.floor(bucketSize));
- const bucketCount = Math.max(1, Math.ceil(buffer.length / safeBucketSize));
- const amplitudes = computePeakBuckets({
- buffer,
- buckets: Array.from({ length: bucketCount }, (_, bucketIndex) => {
- const bucketStart = bucketIndex * safeBucketSize;
- const bucketEnd = Math.min(buffer.length, bucketStart + safeBucketSize);
- return { bucketStart, bucketEnd };
- }),
- });
-
- return {
- sourceKey,
- sampleRate: buffer.sampleRate,
- totalSamples: buffer.length,
- bucketSize: safeBucketSize,
- amplitudes: Float32Array.from(amplitudes),
- };
-}
-
-export function buildWaveformSampleBuckets({
- clipLeftPx,
- clipRightPx,
- barCount,
- pixelsPerSecond,
- clipDurationSec,
- sourceStartSec,
- retime,
- sampleRate,
- maxSampleExclusive,
- barStepPx,
-}: {
- clipLeftPx: number;
- clipRightPx: number;
- barCount: number;
- pixelsPerSecond: number;
- clipDurationSec: number;
- sourceStartSec: number;
- retime?: RetimeConfig;
- sampleRate: number;
- maxSampleExclusive: number;
- barStepPx: number;
-}): SampleBucket[] {
- return Array.from({ length: barCount }, (_, index) => {
- const bucketLeftPx = clipLeftPx + index * barStepPx;
- const bucketRightPx = Math.min(clipRightPx, bucketLeftPx + barStepPx);
- const clipStartSec = Math.max(
- 0,
- Math.min(clipDurationSec, bucketLeftPx / pixelsPerSecond),
- );
- const clipEndSec = Math.max(
- clipStartSec,
- Math.min(clipDurationSec, bucketRightPx / pixelsPerSecond),
- );
- const sourceBucketStartSec =
- sourceStartSec +
- getSourceTimeAtClipTime({
- clipTime: clipStartSec,
- retime,
- });
- const sourceBucketEndSec =
- sourceStartSec +
- getSourceTimeAtClipTime({
- clipTime: clipEndSec,
- retime,
- });
-
- return {
- bucketStart: Math.max(0, Math.floor(sourceBucketStartSec * sampleRate)),
- bucketEnd: Math.min(
- maxSampleExclusive,
- Math.max(0, Math.ceil(sourceBucketEndSec * sampleRate)),
- ),
- };
- });
-}
-
-export function sampleSourceWaveformSummary({
- summary,
- buckets,
-}: {
- summary: SourceWaveformSummary;
- buckets: SampleBucket[];
-}): number[] {
- return buckets.map(({ bucketStart, bucketEnd }) => {
- if (bucketEnd <= bucketStart) {
- return 0;
- }
-
- const startIndex = Math.max(
- 0,
- Math.floor(bucketStart / summary.bucketSize),
- );
- const endIndex = Math.min(
- summary.amplitudes.length,
- Math.max(startIndex + 1, Math.ceil(bucketEnd / summary.bucketSize)),
- );
-
- let maxAmplitude = 0;
- for (let i = startIndex; i < endIndex; i++) {
- const amplitude = summary.amplitudes[i] ?? 0;
- if (amplitude > maxAmplitude) {
- maxAmplitude = amplitude;
- }
- }
-
- return maxAmplitude;
- });
-}
-
-export function computeRmsBuckets({
- buffer,
- buckets,
-}: {
- buffer: AudioBuffer;
- buckets: SampleBucket[];
-}): number[] {
- const channels = buffer.numberOfChannels;
- const maxWindowLength = Math.max(
- 1,
- Math.floor(buffer.sampleRate * RMS_ANALYSIS_WINDOW_SECONDS),
- );
-
- const channelData: Float32Array[] = new Array(channels);
- for (let c = 0; c < channels; c++) {
- channelData[c] = buffer.getChannelData(c);
- }
-
- const result = new Array<number>(buckets.length);
-
- for (let i = 0; i < buckets.length; i++) {
- const { bucketStart, bucketEnd } = buckets[i];
- const bucketLength = bucketEnd - bucketStart;
- if (bucketLength <= 0) {
- result[i] = 0;
- continue;
- }
-
- const windowLength = Math.max(1, Math.min(bucketLength, maxWindowLength));
- let maxMeanSquare = 0;
-
- for (let winStart = bucketStart; winStart < bucketEnd; ) {
- const winEnd = Math.min(winStart + windowLength, bucketEnd);
- const n = winEnd - winStart;
- if (n > 0) {
- let sum = 0;
- for (let c = 0; c < channels; c++) {
- const data = channelData[c];
- for (let j = winStart; j < winEnd; j++) {
- const v = data[j];
- sum += v * v;
- }
- }
- const meanSquare = sum / (n * channels);
- if (meanSquare > maxMeanSquare) {
- maxMeanSquare = meanSquare;
- }
- }
- winStart = winEnd;
- }
-
- result[i] = Math.sqrt(maxMeanSquare);
- }
-
- return result;
-}