lots of stuff

This commit is contained in:
Maze Winther
2026-01-23 15:40:01 +01:00
parent b1701e1b0e
commit c6deceaa89
292 changed files with 28825 additions and 26457 deletions
+233 -233
View File
@@ -1,8 +1,8 @@
import type {
AudioElement,
LibraryAudioElement,
TimelineElement,
TimelineTrack,
AudioElement,
LibraryAudioElement,
TimelineElement,
TimelineTrack,
} from "@/types/timeline";
import type { MediaAsset } from "@/types/assets";
import { canElementHaveAudio } from "@/lib/timeline/element-utils";
@@ -10,316 +10,316 @@ import { canTracktHaveAudio } from "@/lib/timeline";
import { mediaSupportsAudio } from "@/lib/media/media-utils";
export type CollectedAudioElement = Omit<
AudioElement,
"type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl"
AudioElement,
"type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl"
> & { buffer: AudioBuffer };
export function createAudioContext(): AudioContext {
const AudioContextConstructor =
window.AudioContext ||
(window as typeof window & { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext;
const AudioContextConstructor =
window.AudioContext ||
(window as typeof window & { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext;
return new AudioContextConstructor();
return new AudioContextConstructor();
}
export interface DecodedAudio {
samples: Float32Array;
sampleRate: number;
samples: Float32Array;
sampleRate: number;
}
export async function decodeAudioToFloat32({
audioBlob,
audioBlob,
}: {
audioBlob: Blob;
audioBlob: Blob;
}): Promise<DecodedAudio> {
const audioContext = createAudioContext();
const arrayBuffer = await audioBlob.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
const audioContext = createAudioContext();
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);
// 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;
}
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 };
return { samples, sampleRate: audioBuffer.sampleRate };
}
export async function collectAudioElements({
tracks,
mediaAssets,
audioContext,
tracks,
mediaAssets,
audioContext,
}: {
tracks: TimelineTrack[];
mediaAssets: MediaAsset[];
audioContext: AudioContext;
tracks: TimelineTrack[];
mediaAssets: MediaAsset[];
audioContext: AudioContext;
}): Promise<CollectedAudioElement[]> {
const mediaMap = new Map<string, MediaAsset>(
mediaAssets.map((media) => [media.id, media]),
);
const pendingElements: Array<Promise<CollectedAudioElement | null>> = [];
const mediaMap = new Map<string, MediaAsset>(
mediaAssets.map((media) => [media.id, media]),
);
const pendingElements: Array<Promise<CollectedAudioElement | null>> = [];
for (const track of tracks) {
if (canTracktHaveAudio(track) && track.muted) continue;
for (const track of tracks) {
if (canTracktHaveAudio(track) && track.muted) continue;
for (const element of track.elements) {
if (element.type !== "audio") continue;
if (element.duration <= 0) continue;
for (const element of track.elements) {
if (element.type !== "audio") continue;
if (element.duration <= 0) continue;
const isTrackMuted = canTracktHaveAudio(track) && track.muted;
pendingElements.push(
resolveAudioBufferForElement({
element,
mediaMap,
audioContext,
}).then((audioBuffer) => {
if (!audioBuffer) return null;
return {
buffer: audioBuffer,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
muted: element.muted || isTrackMuted,
};
}),
);
}
}
const isTrackMuted = canTracktHaveAudio(track) && track.muted;
pendingElements.push(
resolveAudioBufferForElement({
element,
mediaMap,
audioContext,
}).then((audioBuffer) => {
if (!audioBuffer) return null;
return {
buffer: audioBuffer,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
muted: element.muted || isTrackMuted,
};
}),
);
}
}
const resolvedElements = await Promise.all(pendingElements);
const audioElements: CollectedAudioElement[] = [];
for (const element of resolvedElements) {
if (element) audioElements.push(element);
}
return audioElements;
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,
mediaMap,
audioContext,
}: {
element: AudioElement;
mediaMap: Map<string, MediaAsset>;
audioContext: 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 || asset.type !== "audio") return null;
try {
if (element.sourceType === "upload") {
const asset = mediaMap.get(element.mediaId);
if (!asset || asset.type !== "audio") return null;
const arrayBuffer = await asset.file.arrayBuffer();
return await audioContext.decodeAudioData(arrayBuffer.slice(0));
}
const arrayBuffer = await asset.file.arrayBuffer();
return await audioContext.decodeAudioData(arrayBuffer.slice(0));
}
if (element.buffer) return element.buffer;
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 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;
}
const arrayBuffer = await response.arrayBuffer();
return await audioContext.decodeAudioData(arrayBuffer.slice(0));
} catch (error) {
console.warn("Failed to decode audio:", error);
return null;
}
}
interface AudioMixSource {
file: File;
startTime: number;
duration: number;
trimStart: number;
trimEnd: number;
file: File;
startTime: number;
duration: number;
trimStart: number;
trimEnd: number;
}
async function fetchLibraryAudioSource({
element,
element,
}: {
element: LibraryAudioElement;
element: LibraryAudioElement;
}): Promise<AudioMixSource | null> {
try {
const response = await fetch(element.sourceUrl);
if (!response.ok) {
throw new Error(`Library audio fetch failed: ${response.status}`);
}
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",
});
const blob = await response.blob();
const file = new File([blob], `${element.name}.mp3`, {
type: "audio/mpeg",
});
return {
file,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
};
} catch (error) {
console.warn("Failed to fetch library audio:", error);
return null;
}
return {
file,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
};
} catch (error) {
console.warn("Failed to fetch library audio:", error);
return null;
}
}
function collectMediaAudioSource({
element,
mediaAsset,
element,
mediaAsset,
}: {
element: TimelineElement;
mediaAsset: MediaAsset;
element: TimelineElement;
mediaAsset: MediaAsset;
}): AudioMixSource {
return {
file: mediaAsset.file,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
};
return {
file: mediaAsset.file,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
};
}
export async function collectAudioMixSources({
tracks,
mediaAssets,
tracks,
mediaAssets,
}: {
tracks: TimelineTrack[];
mediaAssets: MediaAsset[];
tracks: TimelineTrack[];
mediaAssets: MediaAsset[];
}): Promise<AudioMixSource[]> {
const audioMixSources: AudioMixSource[] = [];
const mediaMap = new Map<string, MediaAsset>(
mediaAssets.map((asset) => [asset.id, asset]),
);
const pendingLibrarySources: Array<Promise<AudioMixSource | null>> = [];
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 tracks) {
if (canTracktHaveAudio(track) && track.muted) continue;
for (const track of tracks) {
if (canTracktHaveAudio(track) && track.muted) continue;
for (const element of track.elements) {
if (!canElementHaveAudio(element)) continue;
for (const element of track.elements) {
if (!canElementHaveAudio(element)) continue;
if (element.type === "audio") {
if (element.sourceType === "upload") {
const mediaAsset = mediaMap.get(element.mediaId);
if (!mediaAsset) continue;
if (element.type === "audio") {
if (element.sourceType === "upload") {
const mediaAsset = mediaMap.get(element.mediaId);
if (!mediaAsset) continue;
audioMixSources.push(
collectMediaAudioSource({ element, mediaAsset }),
);
} else {
pendingLibrarySources.push(fetchLibraryAudioSource({ element }));
}
continue;
}
audioMixSources.push(
collectMediaAudioSource({ element, mediaAsset }),
);
} else {
pendingLibrarySources.push(fetchLibraryAudioSource({ element }));
}
continue;
}
if (element.type === "video") {
const mediaAsset = mediaMap.get(element.mediaId);
if (!mediaAsset) continue;
if (element.type === "video") {
const mediaAsset = mediaMap.get(element.mediaId);
if (!mediaAsset) continue;
if (mediaSupportsAudio({ media: mediaAsset })) {
audioMixSources.push(
collectMediaAudioSource({ element, mediaAsset }),
);
}
}
}
}
if (mediaSupportsAudio({ media: mediaAsset })) {
audioMixSources.push(
collectMediaAudioSource({ element, mediaAsset }),
);
}
}
}
}
const resolvedLibrarySources = await Promise.all(pendingLibrarySources);
for (const source of resolvedLibrarySources) {
if (source) audioMixSources.push(source);
}
const resolvedLibrarySources = await Promise.all(pendingLibrarySources);
for (const source of resolvedLibrarySources) {
if (source) audioMixSources.push(source);
}
return audioMixSources;
return audioMixSources;
}
export async function createTimelineAudioBuffer({
tracks,
mediaAssets,
duration,
sampleRate = 44100,
tracks,
mediaAssets,
duration,
sampleRate = 44100,
}: {
tracks: TimelineTrack[];
mediaAssets: MediaAsset[];
duration: number;
sampleRate?: number;
tracks: TimelineTrack[];
mediaAssets: MediaAsset[];
duration: number;
sampleRate?: number;
}): Promise<AudioBuffer | null> {
const audioContext = createAudioContext();
const audioContext = createAudioContext();
const audioElements = await collectAudioElements({
tracks,
mediaAssets,
audioContext,
});
const audioElements = await collectAudioElements({
tracks,
mediaAssets,
audioContext,
});
if (audioElements.length === 0) return null;
if (audioElements.length === 0) return null;
const outputChannels = 2;
const outputLength = Math.ceil(duration * sampleRate);
const outputBuffer = audioContext.createBuffer(
outputChannels,
outputLength,
sampleRate,
);
const outputChannels = 2;
const outputLength = Math.ceil(duration * sampleRate);
const outputBuffer = audioContext.createBuffer(
outputChannels,
outputLength,
sampleRate,
);
for (const element of audioElements) {
if (element.muted) continue;
for (const element of audioElements) {
if (element.muted) continue;
mixAudioChannels({
element,
outputBuffer,
outputLength,
sampleRate,
});
}
mixAudioChannels({
element,
outputBuffer,
outputLength,
sampleRate,
});
}
return outputBuffer;
return outputBuffer;
}
function mixAudioChannels({
element,
outputBuffer,
outputLength,
sampleRate,
element,
outputBuffer,
outputLength,
sampleRate,
}: {
element: CollectedAudioElement;
outputBuffer: AudioBuffer;
outputLength: number;
sampleRate: number;
element: CollectedAudioElement;
outputBuffer: AudioBuffer;
outputLength: number;
sampleRate: number;
}): void {
const { buffer, startTime, trimStart, duration: elementDuration } = element;
const { buffer, startTime, trimStart, duration: elementDuration } = element;
const sourceStartSample = Math.floor(trimStart * buffer.sampleRate);
const sourceLengthSamples = Math.floor(elementDuration * buffer.sampleRate);
const outputStartSample = Math.floor(startTime * sampleRate);
const sourceStartSample = Math.floor(trimStart * buffer.sampleRate);
const sourceLengthSamples = Math.floor(elementDuration * buffer.sampleRate);
const outputStartSample = Math.floor(startTime * sampleRate);
const resampleRatio = sampleRate / buffer.sampleRate;
const resampledLength = Math.floor(sourceLengthSamples * resampleRatio);
const resampleRatio = sampleRate / buffer.sampleRate;
const resampledLength = Math.floor(sourceLengthSamples * resampleRatio);
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);
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 < resampledLength; i++) {
const outputIndex = outputStartSample + i;
if (outputIndex >= outputLength) break;
for (let i = 0; i < resampledLength; i++) {
const outputIndex = outputStartSample + i;
if (outputIndex >= outputLength) break;
const sourceIndex = sourceStartSample + Math.floor(i / resampleRatio);
if (sourceIndex >= sourceData.length) break;
const sourceIndex = sourceStartSample + Math.floor(i / resampleRatio);
if (sourceIndex >= sourceData.length) break;
outputData[outputIndex] += sourceData[sourceIndex];
}
}
outputData[outputIndex] += sourceData[sourceIndex];
}
}
}
+17 -17
View File
@@ -3,30 +3,30 @@ import type { MediaAsset, MediaType } from "@/types/assets";
export const SUPPORTS_AUDIO: readonly MediaType[] = ["audio", "video"];
export function mediaSupportsAudio({
media,
media,
}: {
media: MediaAsset | null | undefined;
media: MediaAsset | null | undefined;
}): boolean {
if (!media) return false;
return SUPPORTS_AUDIO.includes(media.type);
if (!media) return false;
return SUPPORTS_AUDIO.includes(media.type);
}
export const getMediaTypeFromFile = ({
file,
file,
}: {
file: File;
file: File;
}): MediaType | null => {
const { type } = file;
const { type } = file;
if (type.startsWith("image/")) {
return "image";
}
if (type.startsWith("video/")) {
return "video";
}
if (type.startsWith("audio/")) {
return "audio";
}
if (type.startsWith("image/")) {
return "image";
}
if (type.startsWith("video/")) {
return "video";
}
if (type.startsWith("audio/")) {
return "audio";
}
return null;
return null;
};
+169 -161
View File
@@ -4,215 +4,223 @@ import type { TimelineTrack } from "@/types/timeline";
import type { MediaAsset } from "@/types/assets";
export async function getVideoInfo({
videoFile,
videoFile,
}: {
videoFile: File;
videoFile: File;
}): Promise<{
duration: number;
width: number;
height: number;
fps: number;
duration: number;
width: number;
height: number;
fps: number;
}> {
const input = new Input({
source: new BlobSource(videoFile),
formats: ALL_FORMATS,
});
const input = new Input({
source: new BlobSource(videoFile),
formats: ALL_FORMATS,
});
const duration = await input.computeDuration();
const videoTrack = await input.getPrimaryVideoTrack();
const duration = await input.computeDuration();
const videoTrack = await input.getPrimaryVideoTrack();
if (!videoTrack) {
throw new Error("No video track found in the file");
}
if (!videoTrack) {
throw new Error("No video track found in the file");
}
const packetStats = await videoTrack.computePacketStats(100);
const fps = packetStats.averagePacketRate;
const packetStats = await videoTrack.computePacketStats(100);
const fps = packetStats.averagePacketRate;
return {
duration,
width: videoTrack.displayWidth,
height: videoTrack.displayHeight,
fps,
};
return {
duration,
width: videoTrack.displayWidth,
height: videoTrack.displayHeight,
fps,
};
}
const SAMPLE_RATE = 44100;
const NUM_CHANNELS = 2;
export const extractTimelineAudio = async ({
tracks,
mediaAssets,
totalDuration,
onProgress,
tracks,
mediaAssets,
totalDuration,
onProgress,
}: {
tracks: TimelineTrack[];
mediaAssets: MediaAsset[];
totalDuration: number;
onProgress?: (progress: number) => void;
tracks: TimelineTrack[];
mediaAssets: MediaAsset[];
totalDuration: number;
onProgress?: (progress: number) => void;
}): Promise<Blob> => {
if (totalDuration === 0) {
return createWavBlob({ samples: new Float32Array(SAMPLE_RATE * 0.1) });
}
if (totalDuration === 0) {
return createWavBlob({ samples: new Float32Array(SAMPLE_RATE * 0.1) });
}
const audioMixSources = await collectAudioMixSources({
tracks,
mediaAssets,
});
const audioMixSources = await collectAudioMixSources({
tracks,
mediaAssets,
});
if (audioMixSources.length === 0) {
const silentDuration = Math.max(1, totalDuration);
const silentSamples = new Float32Array(
Math.ceil(silentDuration * SAMPLE_RATE) * NUM_CHANNELS,
);
return createWavBlob({ samples: silentSamples });
}
if (audioMixSources.length === 0) {
const silentDuration = Math.max(1, totalDuration);
const silentSamples = new Float32Array(
Math.ceil(silentDuration * SAMPLE_RATE) * NUM_CHANNELS,
);
return createWavBlob({ samples: silentSamples });
}
const totalSamples = Math.ceil(totalDuration * SAMPLE_RATE);
const mixBuffers = [
new Float32Array(totalSamples),
new Float32Array(totalSamples),
];
const totalSamples = Math.ceil(totalDuration * SAMPLE_RATE);
const mixBuffers = [
new Float32Array(totalSamples),
new Float32Array(totalSamples),
];
for (let i = 0; i < audioMixSources.length; i++) {
const source = audioMixSources[i];
for (let i = 0; i < audioMixSources.length; i++) {
const source = audioMixSources[i];
if (onProgress) {
onProgress((i / audioMixSources.length) * 90);
}
if (onProgress) {
onProgress((i / audioMixSources.length) * 90);
}
try {
await decodeAndMixAudioSource({
source,
mixBuffers,
totalSamples,
});
} catch (error) {
console.warn(`Failed to process audio source ${source.file.name}:`, error);
}
}
try {
await decodeAndMixAudioSource({
source,
mixBuffers,
totalSamples,
});
} catch (error) {
console.warn(
`Failed to process audio source ${source.file.name}:`,
error,
);
}
}
// clamp to prevent clipping
for (const channel of mixBuffers) {
for (let i = 0; i < channel.length; i++) {
channel[i] = Math.max(-1, Math.min(1, channel[i]));
}
}
// clamp to prevent clipping
for (const channel of mixBuffers) {
for (let i = 0; i < channel.length; i++) {
channel[i] = Math.max(-1, Math.min(1, channel[i]));
}
}
// interleave channels for wav output
const interleavedSamples = new Float32Array(totalSamples * NUM_CHANNELS);
for (let i = 0; i < totalSamples; i++) {
interleavedSamples[i * 2] = mixBuffers[0][i];
interleavedSamples[i * 2 + 1] = mixBuffers[1][i];
}
// interleave channels for wav output
const interleavedSamples = new Float32Array(totalSamples * NUM_CHANNELS);
for (let i = 0; i < totalSamples; i++) {
interleavedSamples[i * 2] = mixBuffers[0][i];
interleavedSamples[i * 2 + 1] = mixBuffers[1][i];
}
if (onProgress) {
onProgress(100);
}
if (onProgress) {
onProgress(100);
}
return createWavBlob({ samples: interleavedSamples });
return createWavBlob({ samples: interleavedSamples });
};
async function decodeAndMixAudioSource({
source,
mixBuffers,
totalSamples,
source,
mixBuffers,
totalSamples,
}: {
source: { file: File; startTime: number; duration: number; trimStart: number };
mixBuffers: Float32Array[];
totalSamples: number;
source: {
file: File;
startTime: number;
duration: number;
trimStart: number;
};
mixBuffers: Float32Array[];
totalSamples: number;
}): Promise<void> {
const input = new Input({
source: new BlobSource(source.file),
formats: ALL_FORMATS,
});
const input = new Input({
source: new BlobSource(source.file),
formats: ALL_FORMATS,
});
const audioTrack = await input.getPrimaryAudioTrack();
if (!audioTrack) return;
const audioTrack = await input.getPrimaryAudioTrack();
if (!audioTrack) return;
const sink = new AudioBufferSink(audioTrack);
const trimEnd = source.trimStart + source.duration;
const sink = new AudioBufferSink(audioTrack);
const trimEnd = source.trimStart + source.duration;
for await (const { buffer, timestamp } of sink.buffers(
source.trimStart,
trimEnd,
)) {
const relativeTime = timestamp - source.trimStart;
const outputStartSample = Math.floor(
(source.startTime + relativeTime) * SAMPLE_RATE,
);
for await (const { buffer, timestamp } of sink.buffers(
source.trimStart,
trimEnd,
)) {
const relativeTime = timestamp - source.trimStart;
const outputStartSample = Math.floor(
(source.startTime + relativeTime) * SAMPLE_RATE,
);
// resample if needed
const resampleRatio = SAMPLE_RATE / buffer.sampleRate;
// resample if needed
const resampleRatio = SAMPLE_RATE / buffer.sampleRate;
for (let ch = 0; ch < NUM_CHANNELS; ch++) {
const sourceChannel = Math.min(ch, buffer.numberOfChannels - 1);
const channelData = buffer.getChannelData(sourceChannel);
const outputChannel = mixBuffers[ch];
for (let ch = 0; ch < NUM_CHANNELS; ch++) {
const sourceChannel = Math.min(ch, buffer.numberOfChannels - 1);
const channelData = buffer.getChannelData(sourceChannel);
const outputChannel = mixBuffers[ch];
const resampledLength = Math.floor(channelData.length * resampleRatio);
for (let i = 0; i < resampledLength; i++) {
const outputIdx = outputStartSample + i;
if (outputIdx < 0 || outputIdx >= totalSamples) continue;
const resampledLength = Math.floor(channelData.length * resampleRatio);
for (let i = 0; i < resampledLength; i++) {
const outputIdx = outputStartSample + i;
if (outputIdx < 0 || outputIdx >= totalSamples) continue;
const sourceIdx = Math.floor(i / resampleRatio);
if (sourceIdx < channelData.length) {
outputChannel[outputIdx] += channelData[sourceIdx];
}
}
}
}
const sourceIdx = Math.floor(i / resampleRatio);
if (sourceIdx < channelData.length) {
outputChannel[outputIdx] += channelData[sourceIdx];
}
}
}
}
}
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);
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" });
// 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);
// 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);
// 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;
}
// 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" });
return new Blob([buffer], { type: "audio/wav" });
}
function writeString({
view,
offset,
str,
view,
offset,
str,
}: {
view: DataView;
offset: number;
str: string;
view: DataView;
offset: number;
str: string;
}): void {
for (let i = 0; i < str.length; i++) {
view.setUint8(offset + i, str.charCodeAt(i));
}
for (let i = 0; i < str.length; i++) {
view.setUint8(offset + i, str.charCodeAt(i));
}
}
+157 -151
View File
@@ -1,5 +1,5 @@
import { toast } from "sonner";
import { MediaAsset } from "@/types/assets";
import type { MediaAsset } from "@/types/assets";
import { getMediaTypeFromFile } from "@/lib/media/media-utils";
import { getVideoInfo } from "./mediabunny";
import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny";
@@ -7,197 +7,203 @@ import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny";
export interface ProcessedMediaAsset extends Omit<MediaAsset, "id"> {}
export async function generateThumbnail({
videoFile,
timeInSeconds,
videoFile,
timeInSeconds,
}: {
videoFile: File;
timeInSeconds: number;
videoFile: File;
timeInSeconds: number;
}): Promise<string> {
const input = new Input({
source: new BlobSource(videoFile),
formats: ALL_FORMATS,
});
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 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 canDecode = await videoTrack.canDecode();
if (!canDecode) {
throw new Error("Video codec not supported for decoding");
}
const sink = new VideoSampleSink(videoTrack);
const sink = new VideoSampleSink(videoTrack);
const frame = await sink.getSample(timeInSeconds);
const frame = await sink.getSample(timeInSeconds);
if (!frame) {
throw new Error("Could not get frame at specified time");
}
if (!frame) {
throw new Error("Could not get frame at specified time");
}
const maxWidth = 1280;
const maxHeight = 720;
const maxWidth = 1280;
const maxHeight = 720;
const videoWidth = videoTrack.displayWidth;
const videoHeight = videoTrack.displayHeight;
const aspectRatio = videoWidth / videoHeight;
const videoWidth = videoTrack.displayWidth;
const videoHeight = videoTrack.displayHeight;
const aspectRatio = videoWidth / videoHeight;
let width = videoWidth;
let height = videoHeight;
let width = videoWidth;
let height = videoHeight;
if (width > maxWidth) {
width = maxWidth;
height = Math.round(width / aspectRatio);
}
if (height > maxHeight) {
height = maxHeight;
width = Math.round(height * aspectRatio);
}
if (width > maxWidth) {
width = maxWidth;
height = Math.round(width / aspectRatio);
}
if (height > maxHeight) {
height = maxHeight;
width = Math.round(height * aspectRatio);
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Could not get canvas context");
}
if (!ctx) {
throw new Error("Could not get canvas context");
}
frame.draw(ctx, 0, 0, width, height);
const dataUrl = canvas.toDataURL("image/jpeg", 0.8);
return dataUrl;
try {
frame.draw(ctx, 0, 0, width, height);
const dataUrl = canvas.toDataURL("image/jpeg", 0.8);
return dataUrl;
} finally {
frame.close();
}
}
export async function processMediaAssets({
files,
onProgress,
files,
onProgress,
}: {
files: FileList | File[];
onProgress?: ({ progress }: { progress: number }) => void;
files: FileList | File[];
onProgress?: ({ progress }: { progress: number }) => void;
}): Promise<ProcessedMediaAsset[]> {
const fileArray = Array.from(files);
const processedAssets: ProcessedMediaAsset[] = [];
const fileArray = Array.from(files);
const processedAssets: ProcessedMediaAsset[] = [];
const total = fileArray.length;
let completed = 0;
const total = fileArray.length;
let completed = 0;
for (const file of fileArray) {
const fileType = getMediaTypeFromFile({ file });
for (const file of fileArray) {
const fileType = getMediaTypeFromFile({ file });
if (!fileType) {
toast.error(`Unsupported file type: ${file.name}`);
continue;
}
if (!fileType) {
toast.error(`Unsupported file type: ${file.name}`);
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;
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;
try {
if (fileType === "image") {
const dimensions = await getImageDimensions({ file });
width = dimensions.width;
height = dimensions.height;
} else if (fileType === "video") {
try {
const videoInfo = await getVideoInfo({ videoFile: file });
duration = videoInfo.duration;
width = videoInfo.width;
height = videoInfo.height;
fps = videoInfo.fps;
try {
if (fileType === "image") {
const dimensions = await getImageDimensions({ file });
width = dimensions.width;
height = dimensions.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 * 1000) / 1000
: undefined;
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 });
}
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,
});
processedAssets.push({
name: file.name,
type: fileType,
file,
url,
thumbnailUrl,
duration,
width,
height,
fps,
});
await new Promise((resolve) => setTimeout(resolve, 0));
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
}
}
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;
return processedAssets;
}
const getImageDimensions = ({
file,
file,
}: {
file: File;
file: File;
}): Promise<{ width: number; height: number }> => {
return new Promise((resolve, reject) => {
const img = new window.Image();
const objectUrl = URL.createObjectURL(file);
return new Promise((resolve, reject) => {
const img = new window.Image();
const objectUrl = URL.createObjectURL(file);
img.addEventListener("load", () => {
const width = img.naturalWidth;
const height = img.naturalHeight;
resolve({ width, height });
URL.revokeObjectURL(objectUrl);
img.remove();
});
img.addEventListener("load", () => {
const width = img.naturalWidth;
const height = img.naturalHeight;
resolve({ width, height });
URL.revokeObjectURL(objectUrl);
img.remove();
});
img.addEventListener("error", () => {
reject(new Error("Could not load image"));
URL.revokeObjectURL(objectUrl);
img.remove();
});
img.addEventListener("error", () => {
reject(new Error("Could not load image"));
URL.revokeObjectURL(objectUrl);
img.remove();
});
img.src = objectUrl;
});
img.src = objectUrl;
});
};
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);
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("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.addEventListener("error", () => {
reject(new Error("Could not load media"));
URL.revokeObjectURL(objectUrl);
element.remove();
});
element.src = objectUrl;
element.load();
});
element.src = objectUrl;
element.load();
});
};