diff options
Diffstat (limited to 'apps/web/src/media')
| -rw-r--r-- | apps/web/src/media/audio-mastering.ts | 103 | ||||
| -rw-r--r-- | apps/web/src/media/audio.ts | 868 | ||||
| -rw-r--r-- | apps/web/src/media/media-utils.ts | 32 | ||||
| -rw-r--r-- | apps/web/src/media/mediabunny.ts | 168 | ||||
| -rw-r--r-- | apps/web/src/media/processing.ts | 296 | ||||
| -rw-r--r-- | apps/web/src/media/types.ts | 9 | ||||
| -rw-r--r-- | apps/web/src/media/upload-toast.ts | 51 | ||||
| -rw-r--r-- | apps/web/src/media/use-file-upload.ts | 101 | ||||
| -rw-r--r-- | apps/web/src/media/use-paste-media.ts | 116 | ||||
| -rw-r--r-- | apps/web/src/media/waveform-summary.ts | 233 |
10 files changed, 1977 insertions, 0 deletions
diff --git a/apps/web/src/media/audio-mastering.ts b/apps/web/src/media/audio-mastering.ts new file mode 100644 index 00000000..f901c3bf --- /dev/null +++ b/apps/web/src/media/audio-mastering.ts @@ -0,0 +1,103 @@ +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/media/audio.ts b/apps/web/src/media/audio.ts new file mode 100644 index 00000000..42756d49 --- /dev/null +++ b/apps/web/src/media/audio.ts @@ -0,0 +1,868 @@ +import type { + AudioElement, + VideoElement, + LibraryAudioElement, + RetimeConfig, + SceneTracks, +} from "@/timeline"; +import { shouldMaintainPitch } from "@/retime/rate"; +import type { MediaAsset } from "@/media/types"; +import { applyAudioMasteringToBuffer } from "@/media/audio-mastering"; +import type { AudioCapableElement } from "@/timeline/audio-state"; +import { + hasAnimatedVolume, + resolveEffectiveAudioGain, +} from "@/timeline/audio-state"; +import { doesElementHaveEnabledAudio } from "@/timeline/audio-separation"; +import { canElementHaveAudio, hasMediaId } from "@/timeline/element-utils"; +import { canTrackHaveAudio } from "@/timeline"; +import { mediaSupportsAudio } from "@/media/media-utils"; +import { getSourceTimeAtClipTime, renderRetimedBuffer } from "@/retime"; +import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { + computeRmsBuckets, + type SampleBucket, +} from "@/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/media/media-utils.ts b/apps/web/src/media/media-utils.ts new file mode 100644 index 00000000..dd59bf31 --- /dev/null +++ b/apps/web/src/media/media-utils.ts @@ -0,0 +1,32 @@ +import type { MediaAsset, MediaType } from "@/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/media/mediabunny.ts b/apps/web/src/media/mediabunny.ts new file mode 100644 index 00000000..47ffaeb1 --- /dev/null +++ b/apps/web/src/media/mediabunny.ts @@ -0,0 +1,168 @@ +import { Input, ALL_FORMATS, BlobSource } from "mediabunny"; +import { createTimelineAudioBuffer } from "@/media/audio"; +import type { SceneTracks } from "@/timeline"; +import type { MediaAsset } from "@/media/types"; +import { TICKS_PER_SECOND } from "@/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/media/processing.ts b/apps/web/src/media/processing.ts new file mode 100644 index 00000000..d8d483f8 --- /dev/null +++ b/apps/web/src/media/processing.ts @@ -0,0 +1,296 @@ +import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny"; +import { toast } from "sonner"; +import { getMediaTypeFromFile } from "@/media/media-utils"; +import { formatStorageBytes } from "@/services/storage/quota"; +import { storageService } from "@/services/storage/service"; +import type { MediaAsset } from "@/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/media/types.ts b/apps/web/src/media/types.ts new file mode 100644 index 00000000..4d3e4106 --- /dev/null +++ b/apps/web/src/media/types.ts @@ -0,0 +1,9 @@ +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/media/upload-toast.ts b/apps/web/src/media/upload-toast.ts new file mode 100644 index 00000000..45846206 --- /dev/null +++ b/apps/web/src/media/upload-toast.ts @@ -0,0 +1,51 @@ +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/media/use-file-upload.ts b/apps/web/src/media/use-file-upload.ts new file mode 100644 index 00000000..1ee30ad9 --- /dev/null +++ b/apps/web/src/media/use-file-upload.ts @@ -0,0 +1,101 @@ +import { useState, useRef } from "react"; +import { hasDragData } from "@/timeline/drag-data"; + +interface UseFileUploadOptions { + accept?: string; + multiple?: boolean; + onFilesSelected?: (files: File[]) => void; +} + +function containsFiles(dataTransfer: DataTransfer): boolean { + return !hasDragData({ dataTransfer }) && dataTransfer.types.includes("Files"); +} + +export function useFileUpload({ + accept, + multiple, + onFilesSelected, +}: UseFileUploadOptions = {}) { + const [isDragOver, setIsDragOver] = useState(false); + const dragCounterRef = useRef(0); + const inputRef = useRef<HTMLInputElement>(null); + + function openFilePicker() { + if (!inputRef.current) return; + + inputRef.current.accept = accept || "*"; + inputRef.current.multiple = multiple || false; + inputRef.current.click(); + } + + function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) { + const files = Array.from(event.target.files ?? []); + if (files.length > 0 && onFilesSelected) { + onFilesSelected(files); + } + + if (event.target) { + event.target.value = ""; + } + } + + function handleDragEnter(e: React.DragEvent) { + e.preventDefault(); + + if (!containsFiles(e.dataTransfer)) return; + + dragCounterRef.current += 1; + setIsDragOver(true); + } + + function handleDragOver(e: React.DragEvent) { + e.preventDefault(); + + if (!containsFiles(e.dataTransfer)) return; + } + + function handleDragLeave(e: React.DragEvent) { + e.preventDefault(); + + if (!containsFiles(e.dataTransfer)) return; + + dragCounterRef.current -= 1; + if (dragCounterRef.current === 0) { + setIsDragOver(false); + } + } + + function handleDrop(e: React.DragEvent) { + e.preventDefault(); + setIsDragOver(false); + dragCounterRef.current = 0; + + if (onFilesSelected && containsFiles(e.dataTransfer)) { + const files = Array.from(e.dataTransfer.files); + const shouldUseMultiple = multiple ?? false; + + if (shouldUseMultiple) { + onFilesSelected(files); + } else if (files.length > 0) { + onFilesSelected([files[0]]); + } + } + } + + return { + isDragOver, + openFilePicker, + fileInputProps: { + ref: inputRef, + type: "file", + style: { display: "none" }, + onChange: handleFileChange, + }, + dragProps: { + onDragEnter: handleDragEnter, + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + }, + }; +} diff --git a/apps/web/src/media/use-paste-media.ts b/apps/web/src/media/use-paste-media.ts new file mode 100644 index 00000000..3055920e --- /dev/null +++ b/apps/web/src/media/use-paste-media.ts @@ -0,0 +1,116 @@ +import { useEffect } from "react"; +import { useEditor } from "@/editor/use-editor"; +import { processMediaAssets } from "@/media/processing"; +import { showMediaUploadToast } from "@/media/upload-toast"; +import { buildElementFromMedia } from "@/timeline/element-utils"; +import { AddMediaAssetCommand } from "@/commands/media"; +import { InsertElementCommand } from "@/commands/timeline"; +import { BatchCommand } from "@/commands"; +import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { isTypableDOMElement } from "@/utils/browser"; +import type { MediaType } from "@/media/types"; + +const MEDIA_MIME_PREFIXES: MediaType[] = ["image", "video", "audio"]; + +function isMediaMimeType({ type }: { type: string }): boolean { + return MEDIA_MIME_PREFIXES.some((prefix) => type.startsWith(`${prefix}/`)); +} + +function extractMediaFilesFromClipboard({ + clipboardData, +}: { + clipboardData: DataTransfer | null; +}): File[] { + if (!clipboardData?.items) return []; + + const files: File[] = []; + for (const item of clipboardData.items) { + if (item.kind !== "file") continue; + if (!isMediaMimeType({ type: item.type })) continue; + + const file = item.getAsFile(); + if (file) files.push(file); + } + return files; +} + +export function usePasteMedia() { + const editor = useEditor(); + + useEffect(() => { + const handlePaste = async (event: ClipboardEvent) => { + const activeElement = document.activeElement as HTMLElement; + + if (activeElement && isTypableDOMElement({ element: activeElement })) { + return; + } + + const files = extractMediaFilesFromClipboard({ + clipboardData: event.clipboardData, + }); + if (files.length === 0) { + event.preventDefault(); + editor.clipboard.paste(); + return; + } + + event.preventDefault(); + + const activeProject = editor.project.getActive(); + if (!activeProject) return; + + try { + await showMediaUploadToast({ + filesCount: files.length, + promise: async () => { + const processedAssets = await processMediaAssets({ files }); + const startTime = editor.playback.getCurrentTime(); + + for (const asset of processedAssets) { + const addMediaCmd = new AddMediaAssetCommand( + activeProject.metadata.id, + asset, + ); + const assetId = addMediaCmd.getAssetId(); + const duration = + asset.duration != null + ? Math.round(asset.duration * TICKS_PER_SECOND) + : DEFAULT_NEW_ELEMENT_DURATION; + const trackType = asset.type === "audio" ? "audio" : "video"; + + const element = buildElementFromMedia({ + mediaId: assetId, + mediaType: asset.type, + name: asset.name, + duration, + startTime, + buffer: + asset.type === "audio" + ? new AudioBuffer({ length: 1, sampleRate: 44100 }) + : undefined, + }); + + const insertCmd = new InsertElementCommand({ + element, + placement: { mode: "auto", trackType }, + }); + const batchCmd = new BatchCommand([addMediaCmd, insertCmd]); + editor.command.execute({ command: batchCmd }); + } + + return { + uploadedCount: processedAssets.length, + assetNames: processedAssets.map((asset) => asset.name), + }; + }, + }); + } catch (error) { + console.error("Failed to paste media:", error); + } + }; + + window.addEventListener("paste", handlePaste); + return () => window.removeEventListener("paste", handlePaste); + }, [editor]); +} diff --git a/apps/web/src/media/waveform-summary.ts b/apps/web/src/media/waveform-summary.ts new file mode 100644 index 00000000..03a0059c --- /dev/null +++ b/apps/web/src/media/waveform-summary.ts @@ -0,0 +1,233 @@ +"use client"; + +import { getSourceTimeAtClipTime } from "@/retime"; +import type { RetimeConfig } from "@/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; +} |
