From 4d127a038bf768eded68aae3cfa5c1b12241bdac Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 27 Mar 2026 00:55:15 +0100 Subject: chore: normalize line endings --- apps/web/src/lib/media/audio-mastering.ts | 206 +++++++++---------- apps/web/src/lib/media/media-utils.ts | 64 +++--- apps/web/src/lib/media/mediabunny.ts | 316 +++++++++++++++--------------- apps/web/src/lib/media/types.ts | 18 +- apps/web/src/lib/media/upload-toast.ts | 102 +++++----- 5 files changed, 353 insertions(+), 353 deletions(-) (limited to 'apps/web/src/lib/media') diff --git a/apps/web/src/lib/media/audio-mastering.ts b/apps/web/src/lib/media/audio-mastering.ts index 29af223b..f901c3bf 100644 --- a/apps/web/src/lib/media/audio-mastering.ts +++ b/apps/web/src/lib/media/audio-mastering.ts @@ -1,103 +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 { - 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]), - ); - } - } -} +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 { + 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/media-utils.ts b/apps/web/src/lib/media/media-utils.ts index 52c08f0c..dc73da23 100644 --- a/apps/web/src/lib/media/media-utils.ts +++ b/apps/web/src/lib/media/media-utils.ts @@ -1,32 +1,32 @@ -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; -}; +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 index c2d1d7f3..dbb6ecad 100644 --- a/apps/web/src/lib/media/mediabunny.ts +++ b/apps/web/src/lib/media/mediabunny.ts @@ -1,158 +1,158 @@ -import { Input, ALL_FORMATS, BlobSource } from "mediabunny"; -import { createTimelineAudioBuffer } from "@/lib/media/audio"; -import type { TimelineTrack } from "@/lib/timeline"; -import type { MediaAsset } from "@/lib/media/types"; - -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; - -export const extractTimelineAudio = async ({ - tracks, - mediaAssets, - totalDuration, - onProgress, -}: { - tracks: TimelineTrack[]; - mediaAssets: MediaAsset[]; - totalDuration: number; - onProgress?: (progress: number) => void; -}): Promise => { - if (totalDuration === 0) { - return createWavBlob({ samples: new Float32Array(SAMPLE_RATE * 0.1) }); - } - - onProgress?.(10); - - const audioBuffer = await createTimelineAudioBuffer({ - tracks, - mediaAssets, - duration: totalDuration, - sampleRate: SAMPLE_RATE, - }); - - if (!audioBuffer) { - const silentDuration = Math.max(1, totalDuration); - const silentSamples = new Float32Array( - Math.ceil(silentDuration * 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)); - } -} +import { Input, ALL_FORMATS, BlobSource } from "mediabunny"; +import { createTimelineAudioBuffer } from "@/lib/media/audio"; +import type { TimelineTrack } from "@/lib/timeline"; +import type { MediaAsset } from "@/lib/media/types"; + +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; + +export const extractTimelineAudio = async ({ + tracks, + mediaAssets, + totalDuration, + onProgress, +}: { + tracks: TimelineTrack[]; + mediaAssets: MediaAsset[]; + totalDuration: number; + onProgress?: (progress: number) => void; +}): Promise => { + if (totalDuration === 0) { + return createWavBlob({ samples: new Float32Array(SAMPLE_RATE * 0.1) }); + } + + onProgress?.(10); + + const audioBuffer = await createTimelineAudioBuffer({ + tracks, + mediaAssets, + duration: totalDuration, + sampleRate: SAMPLE_RATE, + }); + + if (!audioBuffer) { + const silentDuration = Math.max(1, totalDuration); + const silentSamples = new Float32Array( + Math.ceil(silentDuration * 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/types.ts b/apps/web/src/lib/media/types.ts index c8dddfc7..4d3e4106 100644 --- a/apps/web/src/lib/media/types.ts +++ b/apps/web/src/lib/media/types.ts @@ -1,9 +1,9 @@ -import type { MediaAssetData } from "@/services/storage/types"; - -export type MediaType = "image" | "video" | "audio"; - -export interface MediaAsset - extends Omit { - file: File; - url?: string; -} +import type { MediaAssetData } from "@/services/storage/types"; + +export type MediaType = "image" | "video" | "audio"; + +export interface MediaAsset + extends Omit { + file: File; + url?: string; +} diff --git a/apps/web/src/lib/media/upload-toast.ts b/apps/web/src/lib/media/upload-toast.ts index e5c5ee28..45846206 100644 --- a/apps/web/src/lib/media/upload-toast.ts +++ b/apps/web/src/lib/media/upload-toast.ts @@ -1,51 +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 { - return new Promise((resolve) => { - requestAnimationFrame(() => { - requestAnimationFrame(() => resolve()); - }); - }); -} - -export async function showMediaUploadToast({ - filesCount, - promise, -}: { - filesCount: number; - promise: Promise | (() => Promise); -}) { - 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(); -} +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 { + return new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()); + }); + }); +} + +export async function showMediaUploadToast({ + filesCount, + promise, +}: { + filesCount: number; + promise: Promise | (() => Promise); +}) { + 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(); +} -- cgit v1.3.1