mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
lots of stuff
This commit is contained in:
+233
-233
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user