summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/media
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-27 00:55:15 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-29 15:56:48 +0200
commit4d127a038bf768eded68aae3cfa5c1b12241bdac (patch)
tree16bbb801caf6770546cff4fdb37b6408ec9ead98 /apps/web/src/lib/media
parent82817cb50cb0487350573d6129130aec705f924b (diff)
downloadopencut-4d127a038bf768eded68aae3cfa5c1b12241bdac.tar.gz
chore: normalize line endings
Diffstat (limited to 'apps/web/src/lib/media')
-rw-r--r--apps/web/src/lib/media/audio-mastering.ts206
-rw-r--r--apps/web/src/lib/media/media-utils.ts64
-rw-r--r--apps/web/src/lib/media/mediabunny.ts316
-rw-r--r--apps/web/src/lib/media/types.ts18
-rw-r--r--apps/web/src/lib/media/upload-toast.ts102
5 files changed, 353 insertions, 353 deletions
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<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]),
- );
- }
- }
-}
+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/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<Blob> => {
- 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<Blob> => {
+ 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<MediaAssetData, "size" | "lastModified"> {
- file: File;
- url?: string;
-}
+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
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<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();
-}
+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();
+}