Browse Source

feat(41-01): port audio and video processing hooks from easy-peasy-ease

- Port useAudioVisualization.ts with waveform peak extraction
- Port useAudioMixing.ts with prepareAudioAsync standalone function
- Port useStitchVideos.ts with stitchVideosAsync and checkEncoderSupport
- Export applyFades from useAudioMixing for shared use
- Remove preview mode logic from useStitchVideos (always full quality)
- Inline constants (DEFAULT_BITRATE, MAX_OUTPUT_FPS) in useStitchVideos
- All hooks use MediaBunny for audio/video processing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
067a0b689a
  1. 213
      src/hooks/useAudioMixing.ts
  2. 144
      src/hooks/useAudioVisualization.ts
  3. 500
      src/hooks/useStitchVideos.ts

213
src/hooks/useAudioMixing.ts

@ -0,0 +1,213 @@
'use client';
import { useCallback } from 'react';
import {
Input,
AudioBufferSink,
BlobSource,
ALL_FORMATS,
} from 'mediabunny';
const MINIMUM_AUDIO_DURATION = 0.1;
interface AudioProcessingOptions {
offset?: number;
fadeIn?: number;
fadeOut?: number;
}
interface AudioMixProgress {
message: string;
progress: number;
}
interface AudioData {
buffer: AudioBuffer;
duration: number;
}
interface UseAudioMixingReturn {
prepareAudio: (
audioBlob: Blob,
videoDuration: number,
onProgress?: (progress: AudioMixProgress) => void,
options?: AudioProcessingOptions
) => Promise<AudioData | null>;
}
/**
* Standalone async function to prepare audio for mixing with video
*/
export async function prepareAudioAsync(
audioBlob: Blob,
videoDuration: number,
onProgress?: (progress: AudioMixProgress) => void,
options?: AudioProcessingOptions
): Promise<AudioData | null> {
try {
onProgress?.({ message: 'Loading audio file...', progress: 10 });
const blobSource = new BlobSource(audioBlob);
const input = new Input({ source: blobSource, formats: ALL_FORMATS });
onProgress?.({ message: 'Reading audio tracks...', progress: 20 });
const audioTracks = await input.getAudioTracks();
if (audioTracks.length === 0) {
throw new Error('No audio tracks found in file');
}
const audioTrack = audioTracks[0];
const sink = new AudioBufferSink(audioTrack);
const audioDuration = await input.computeDuration();
onProgress?.({ message: 'Decoding audio...', progress: 30 });
const decodedBuffers: AudioBuffer[] = [];
for await (const wrappedBuffer of sink.buffers(0, Math.max(videoDuration, audioDuration))) {
if (wrappedBuffer?.buffer) {
decodedBuffers.push(wrappedBuffer.buffer);
}
}
if (decodedBuffers.length === 0) {
throw new Error('Failed to decode audio');
}
const sampleRate = decodedBuffers[0].sampleRate;
const channels = decodedBuffers[0].numberOfChannels;
const targetDuration = Math.max(MINIMUM_AUDIO_DURATION, videoDuration);
const totalSamples = Math.max(1, Math.floor(targetDuration * sampleRate));
const mergedBuffer = new AudioBuffer({
length: totalSamples,
numberOfChannels: channels,
sampleRate,
});
const offsetSeconds = options?.offset ?? 0;
const offsetSamples = Math.floor(offsetSeconds * sampleRate);
let writeOffset = 0;
let sourceSkipSamples = 0;
if (offsetSamples > 0) {
writeOffset = Math.min(offsetSamples, totalSamples);
} else if (offsetSamples < 0) {
sourceSkipSamples = Math.abs(offsetSamples);
}
let samplesToSkip = sourceSkipSamples;
let bufferIndex = 0;
let bufferOffset = 0;
while (samplesToSkip > 0 && bufferIndex < decodedBuffers.length) {
const buffer = decodedBuffers[bufferIndex];
const availableInBuffer = buffer.length - bufferOffset;
if (samplesToSkip >= availableInBuffer) {
samplesToSkip -= availableInBuffer;
bufferIndex++;
bufferOffset = 0;
} else {
bufferOffset = samplesToSkip;
samplesToSkip = 0;
}
}
while (writeOffset < totalSamples && bufferIndex < decodedBuffers.length) {
const buffer = decodedBuffers[bufferIndex];
const remainingSamples = totalSamples - writeOffset;
const availableInBuffer = buffer.length - bufferOffset;
const writeLength = Math.min(availableInBuffer, remainingSamples);
for (let channel = 0; channel < channels; channel++) {
const channelData = buffer.getChannelData(channel).subarray(bufferOffset, bufferOffset + writeLength);
mergedBuffer.getChannelData(channel).set(channelData, writeOffset);
}
writeOffset += writeLength;
bufferOffset += writeLength;
if (bufferOffset >= buffer.length) {
bufferIndex++;
bufferOffset = 0;
}
}
while (writeOffset < totalSamples && decodedBuffers.length > 0) {
for (const buffer of decodedBuffers) {
const remainingSamples = totalSamples - writeOffset;
if (remainingSamples <= 0) break;
const writeLength = Math.min(buffer.length, remainingSamples);
for (let channel = 0; channel < channels; channel++) {
const channelData = buffer.getChannelData(channel).subarray(0, writeLength);
mergedBuffer.getChannelData(channel).set(channelData, writeOffset);
}
writeOffset += writeLength;
}
}
applyFades(mergedBuffer, options);
onProgress?.({ message: 'Audio ready for mixing', progress: 95 });
return {
buffer: mergedBuffer,
duration: totalSamples / sampleRate,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('Audio mixing error:', error);
throw new Error(`Failed to process audio: ${errorMessage}`);
}
}
/**
* React hook wrapper for prepareAudioAsync
*/
export const useAudioMixing = (): UseAudioMixingReturn => {
const prepareAudio = useCallback(
async (
audioBlob: Blob,
videoDuration: number,
onProgress?: (progress: AudioMixProgress) => void,
options?: AudioProcessingOptions
): Promise<AudioData | null> => {
return prepareAudioAsync(audioBlob, videoDuration, onProgress, options);
},
[]
);
return { prepareAudio };
};
export function applyFades(buffer: AudioBuffer, options?: { fadeIn?: number; fadeOut?: number }) {
if (!options) return;
const fadeInSeconds = Math.max(0, options.fadeIn ?? 0);
const fadeOutSeconds = Math.max(0, options.fadeOut ?? 0);
if (fadeInSeconds === 0 && fadeOutSeconds === 0) return;
const totalSamples = buffer.length;
if (totalSamples === 0) return;
let fadeInSamples = Math.min(totalSamples, Math.floor(fadeInSeconds * buffer.sampleRate));
let fadeOutSamples = Math.min(totalSamples, Math.floor(fadeOutSeconds * buffer.sampleRate));
if (fadeInSamples + fadeOutSamples > totalSamples) {
const scale = totalSamples / Math.max(1, fadeInSamples + fadeOutSamples);
fadeInSamples = Math.floor(fadeInSamples * scale);
fadeOutSamples = Math.floor(fadeOutSamples * scale);
}
for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
const channelData = buffer.getChannelData(channel);
if (fadeInSamples > 0) {
for (let i = 0; i < fadeInSamples; i++) {
channelData[i] *= i / fadeInSamples;
}
}
if (fadeOutSamples > 0) {
for (let i = 0; i < fadeOutSamples; i++) {
const sampleIndex = totalSamples - fadeOutSamples + i;
if (sampleIndex < 0 || sampleIndex >= totalSamples) continue;
channelData[sampleIndex] *= (fadeOutSamples - i) / fadeOutSamples;
}
}
}
}

144
src/hooks/useAudioVisualization.ts

@ -0,0 +1,144 @@
import { useState, useEffect } from 'react';
export interface WaveformData {
channelData: Float32Array[];
sampleRate: number;
duration: number;
peaks: number[];
}
const waveformCache = new Map<string, WaveformData>();
const blobKeyStore = new WeakMap<Blob, string>();
let blobKeyCounter = 0;
let sharedAudioContext: AudioContext | null = null;
const getBlobCacheKey = (audioFile: File | Blob | null) => {
if (!audioFile) return null;
if (audioFile instanceof File) {
return `${audioFile.name}-${audioFile.size}-${audioFile.lastModified}`;
}
const existing = blobKeyStore.get(audioFile);
if (existing) return existing;
const generated = `blob-${blobKeyCounter++}-${audioFile.size}-${audioFile.type ?? 'unknown'}`;
blobKeyStore.set(audioFile, generated);
return generated;
};
const getSharedAudioContext = () => {
if (sharedAudioContext) {
return sharedAudioContext;
}
const AudioCtor =
typeof window !== 'undefined'
? window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
: null;
if (!AudioCtor) {
throw new Error('AudioContext is not supported in this environment');
}
sharedAudioContext = new AudioCtor();
return sharedAudioContext;
};
const decodeAudioBuffer = async (context: AudioContext, arrayBuffer: ArrayBuffer) => {
if ('decodeAudioData' in context && context.decodeAudioData.length === 1) {
return context.decodeAudioData(arrayBuffer);
}
return new Promise<AudioBuffer>((resolve, reject) => {
context.decodeAudioData(arrayBuffer, resolve, reject);
});
};
export function useAudioVisualization(audioFile: File | Blob | null) {
const [waveformData, setWaveformData] = useState<WaveformData | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!audioFile) {
setWaveformData(null);
setError(null);
setIsLoading(false);
return;
}
const cacheKey = getBlobCacheKey(audioFile);
if (cacheKey && waveformCache.has(cacheKey)) {
setWaveformData(waveformCache.get(cacheKey)!);
setIsLoading(false);
setError(null);
return;
}
let isCancelled = false;
const processAudio = async () => {
try {
setIsLoading(true);
setError(null);
const arrayBuffer = await audioFile.arrayBuffer();
const audioContext = getSharedAudioContext();
const audioBuffer = await decodeAudioBuffer(audioContext, arrayBuffer);
const channelData = Array.from({ length: audioBuffer.numberOfChannels }, (_, i) =>
audioBuffer.getChannelData(i)
);
const peaks = calculatePeaks(channelData, 256);
const nextWaveform: WaveformData = {
channelData,
sampleRate: audioBuffer.sampleRate,
duration: audioBuffer.duration,
peaks,
};
if (cacheKey) {
waveformCache.set(cacheKey, nextWaveform);
}
if (!isCancelled) {
setWaveformData(nextWaveform);
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to process audio';
setError(message);
console.error('Audio processing error:', err);
} finally {
if (!isCancelled) {
setIsLoading(false);
}
}
};
processAudio();
return () => {
isCancelled = true;
};
}, [audioFile]);
return { waveformData, isLoading, error };
}
function calculatePeaks(channelData: Float32Array[], resolution: number): number[] {
const peaks: number[] = [];
const totalSamples = channelData[0]?.length ?? 0;
const samplesPerPeak = Math.max(1, Math.floor(totalSamples / resolution));
for (let i = 0; i < resolution; i++) {
let max = 0;
const start = i * samplesPerPeak;
const end = Math.min(start + samplesPerPeak, totalSamples);
for (const channel of channelData) {
for (let j = start; j < end; j++) {
max = Math.max(max, Math.abs(channel[j] ?? 0));
}
}
peaks.push(max);
}
return peaks;
}

500
src/hooks/useStitchVideos.ts

@ -0,0 +1,500 @@
'use client';
import { useState, useCallback } from 'react';
import {
Input,
Output,
VideoSampleSink,
VideoSampleSource,
AudioBufferSource,
BlobSource,
ALL_FORMATS,
BufferTarget,
Mp4OutputFormat,
getFirstEncodableAudioCodec,
canEncodeVideo,
} from 'mediabunny';
import type { Rotation } from 'mediabunny';
import { createAvcEncodingConfig, AVC_LEVEL_4_0, AVC_LEVEL_5_1 } from '@/lib/video-encoding';
// Inlined constants from easy-peasy-ease
const DEFAULT_BITRATE = 8_000_000; // 8 Mbps
const MAX_OUTPUT_FPS = 60;
const FALLBACK_WIDTH = 1920;
const FALLBACK_HEIGHT = 1080;
const BASELINE_PIXEL_LIMIT = 1920 * 1080;
const ensureEvenDimension = (value: number): number => {
if (!Number.isFinite(value) || value <= 0) {
return 0;
}
const even = value % 2 === 0 ? value : value - 1;
return even > 0 ? even : 2;
};
const normalizeRotation = (value: unknown): Rotation => {
return value === 0 || value === 90 || value === 180 || value === 270 ? value : 0;
};
const probeVideoMetadata = async (
blob: Blob
): Promise<{ width: number; height: number; rotation: Rotation; bitrate: number }> => {
const source = new BlobSource(blob);
const input = new Input({
source,
formats: ALL_FORMATS,
});
try {
const videoTracks = await input.getVideoTracks();
if (videoTracks.length === 0) {
throw new Error('No video tracks found while probing dimensions.');
}
const track = videoTracks[0];
const widthCandidate =
(typeof track.displayWidth === 'number' && track.displayWidth > 0
? track.displayWidth
: track.codedWidth) ?? FALLBACK_WIDTH;
const heightCandidate =
(typeof track.displayHeight === 'number' && track.displayHeight > 0
? track.displayHeight
: track.codedHeight) ?? FALLBACK_HEIGHT;
// Probe bitrate from packet stats
let bitrate = 0;
try {
const packetStats = await track.computePacketStats();
if (packetStats?.averageBitrate && Number.isFinite(packetStats.averageBitrate)) {
bitrate = packetStats.averageBitrate;
}
} catch (e) {
console.warn('Failed to compute packet stats for bitrate', e);
}
return {
width: ensureEvenDimension(widthCandidate),
height: ensureEvenDimension(heightCandidate),
rotation: normalizeRotation(track.rotation),
bitrate,
};
} finally {
input.dispose();
}
};
const determineEncodeParameters = async (
blobs: Blob[]
): Promise<{ width: number; height: number; rotation: Rotation; maxSourceBitrate: number }> => {
let maxWidth = 0;
let maxHeight = 0;
let maxSourceBitrate = 0;
let rotation: Rotation | null = null;
for (let i = 0; i < blobs.length; i++) {
try {
const { width, height, rotation: trackRotation, bitrate } = await probeVideoMetadata(blobs[i]);
maxWidth = Math.max(maxWidth, width);
maxHeight = Math.max(maxHeight, height);
maxSourceBitrate = Math.max(maxSourceBitrate, bitrate);
if (rotation === null) {
rotation = trackRotation;
} else if (trackRotation !== rotation) {
console.warn(
`Rotation mismatch detected for video ${i + 1} (got ${trackRotation}, expected ${rotation}). Using the first rotation value.`
);
}
} catch (error) {
console.warn(`Failed to probe metadata for video ${i + 1}`, error);
}
}
if (maxWidth <= 0 || maxHeight <= 0) {
return {
width: FALLBACK_WIDTH,
height: FALLBACK_HEIGHT,
rotation: rotation ?? (0 as Rotation),
maxSourceBitrate,
};
}
return {
width: ensureEvenDimension(maxWidth),
height: ensureEvenDimension(maxHeight),
rotation: rotation ?? (0 as Rotation),
maxSourceBitrate,
};
};
export interface StitchProgress {
status: 'idle' | 'processing' | 'complete' | 'error';
message: string;
progress: number; // 0-100
currentVideo?: number; // Which video is being processed (1-indexed)
totalVideos?: number;
error?: string;
}
export interface AudioData {
buffer: AudioBuffer;
duration: number;
}
/**
* Check if the device encoder supports AVC encoding
*/
export async function checkEncoderSupport(): Promise<boolean> {
try {
return await canEncodeVideo('avc', {
width: 1920,
height: 1080,
bitrate: DEFAULT_BITRATE,
});
} catch {
return false;
}
}
/**
* Standalone async function to stitch multiple video blobs together sequentially
*/
export async function stitchVideosAsync(
videoBlobs: Blob[],
audioData?: AudioData | null,
onProgress?: (progress: StitchProgress) => void
): Promise<Blob> {
try {
// Initialize progress
const initialProgress: StitchProgress = {
status: 'processing',
message: 'Initializing stitching...',
progress: 0,
totalVideos: videoBlobs.length,
};
onProgress?.(initialProgress);
if (videoBlobs.length === 0) {
throw new Error('No videos to stitch');
}
// Helper to update progress
const updateProgress = (
status: StitchProgress['status'],
message: string,
progressValue: number,
currentVideo?: number
) => {
const p: StitchProgress = {
status,
message,
progress: progressValue,
currentVideo,
totalVideos: videoBlobs.length,
};
onProgress?.(p);
};
updateProgress('processing', 'Analyzing video metadata...', 5);
const {
width: probedWidth,
height: probedHeight,
rotation: aggregateRotation,
maxSourceBitrate,
} = await determineEncodeParameters(videoBlobs);
const safeWidth = probedWidth > 0 ? probedWidth : FALLBACK_WIDTH;
const safeHeight = probedHeight > 0 ? probedHeight : FALLBACK_HEIGHT;
const codecProfile =
safeWidth * safeHeight > BASELINE_PIXEL_LIMIT ? AVC_LEVEL_5_1 : AVC_LEVEL_4_0;
// Use highest of default/source bitrate
const candidateBitrate = Math.max(
DEFAULT_BITRATE,
Number.isFinite(maxSourceBitrate) && maxSourceBitrate > 0 ? maxSourceBitrate : 0
);
const resolvedBitrate = Math.max(1, Math.floor(candidateBitrate));
const supportsConfig = await canEncodeVideo('avc', {
width: safeWidth,
height: safeHeight,
bitrate: resolvedBitrate,
fullCodecString: codecProfile,
});
if (!supportsConfig) {
throw new Error(
`Device encoder cannot output ${safeWidth}x${safeHeight} using profile ${codecProfile}. Reduce the resolution or bitrate and try again.`
);
}
console.log('Stitch encoder configuration', {
width: safeWidth,
height: safeHeight,
codecProfile,
bitrate: resolvedBitrate,
maxSourceBitrate,
rotation: aggregateRotation,
});
// Create output
updateProgress('processing', 'Creating output container...', 10);
const videoSource = new VideoSampleSource(
createAvcEncodingConfig(resolvedBitrate, safeWidth, safeHeight, codecProfile)
);
const bufferTarget = new BufferTarget();
const output = new Output({
format: new Mp4OutputFormat({ fastStart: 'in-memory' }),
target: bufferTarget,
});
output.addVideoTrack(videoSource, { rotation: aggregateRotation, frameRate: MAX_OUTPUT_FPS });
// Add audio track if provided
let audioSource: AudioBufferSource | undefined;
let pendingAudioBuffer: AudioBuffer | null = null;
if (audioData) {
updateProgress('processing', 'Detecting supported audio codec...', 8);
// Detect the best supported audio codec for MP4
// Try common codecs in order of preference: aac, mp3 (no opus - Twitter doesn't support it)
const audioCodec = await getFirstEncodableAudioCodec(['aac', 'mp3'], {
numberOfChannels: audioData.buffer.numberOfChannels,
sampleRate: audioData.buffer.sampleRate,
bitrate: 128000,
});
if (!audioCodec) {
console.warn('No supported audio codec found, continuing without audio');
} else {
updateProgress('processing', `Adding audio track (${audioCodec})...`, 10);
// Create audio source from the decoded audio buffer
audioSource = new AudioBufferSource({
codec: audioCodec,
bitrate: 128000,
});
output.addAudioTrack(audioSource);
pendingAudioBuffer = audioData.buffer;
}
}
await output.start();
// Track the highest timestamp we've written to ensure monotonicity
// Start at -frameInterval so first frame can be at timestamp 0
const frameInterval = 1 / MAX_OUTPUT_FPS;
let highestWrittenTimestamp = -frameInterval;
// Process each video blob
for (let videoIndex = 0; videoIndex < videoBlobs.length; videoIndex++) {
const videoBlob = videoBlobs[videoIndex];
const videoNumber = videoIndex + 1;
updateProgress(
'processing',
`Processing video ${videoNumber}/${videoBlobs.length}...`,
5 + (videoIndex / videoBlobs.length) * 90,
videoNumber
);
// Create input for this video
const blobSource = new BlobSource(videoBlob);
const input = new Input({
source: blobSource,
formats: ALL_FORMATS,
});
try {
const videoTracks = await input.getVideoTracks();
if (videoTracks.length === 0) {
console.warn(`No video tracks in video ${videoNumber}`);
continue;
}
const videoTrack = videoTracks[0];
const sink = new VideoSampleSink(videoTrack);
// Get duration of this video
const videoDuration = await input.computeDuration();
// Track the base offset for this video segment
const segmentBaseTime = highestWrittenTimestamp;
// Track minimum timestamp in this segment to normalize
let segmentMinTimestamp: number | null = null;
// Read and write samples from this video
let samplesFromThisVideo = 0;
for await (const sample of sink.samples(0, videoDuration)) {
const originalTimestamp = sample.timestamp ?? 0;
// On first sample, record the minimum timestamp to normalize from
if (segmentMinTimestamp === null) {
segmentMinTimestamp = originalTimestamp;
}
// Normalize timestamp relative to segment start, then offset by base time
const normalizedTimestamp = originalTimestamp - segmentMinTimestamp;
const adjustedTimestamp = segmentBaseTime + normalizedTimestamp;
// Snap to 60fps grid for consistent framerate
const snappedTimestamp = Math.round(adjustedTimestamp / frameInterval) * frameInterval;
// Skip duplicate frames that land on the same timestamp slot
// This ensures strict 60fps without exceeding the target rate
if (snappedTimestamp <= highestWrittenTimestamp) {
sample.close();
continue;
}
sample.setTimestamp(snappedTimestamp);
sample.setDuration(frameInterval);
await videoSource.add(sample);
// Update highest written timestamp
highestWrittenTimestamp = snappedTimestamp;
sample.close();
samplesFromThisVideo++;
// Update progress
if (samplesFromThisVideo % 10 === 0) {
const videoProgress = samplesFromThisVideo / 300; // Rough estimate
const overallProgress =
5 + ((videoIndex + videoProgress) / videoBlobs.length) * 90;
updateProgress(
'processing',
`Processing video ${videoNumber}/${videoBlobs.length}: ${samplesFromThisVideo} frames...`,
overallProgress,
videoNumber
);
}
}
} catch (videoError) {
const errorMsg =
videoError instanceof Error
? videoError.message
: `Failed to process video ${videoNumber}`;
console.error(`Error processing video ${videoNumber}:`, videoError);
throw new Error(errorMsg);
} finally {
input.dispose();
}
}
// Encode audio after all video frames have been queued so container metadata stays accurate
if (audioSource && pendingAudioBuffer) {
updateProgress('processing', 'Encoding audio track...', 92);
await audioSource.add(pendingAudioBuffer);
await audioSource.close();
}
// Flush encoder before finalizing container
await videoSource.close();
updateProgress('processing', 'Finalizing stitched video...', 97);
// Finalize output
await output.finalize();
const buffer = bufferTarget.buffer;
if (!buffer) {
throw new Error('Failed to generate output buffer');
}
const outputBlob = new Blob([buffer], { type: 'video/mp4' });
updateProgress(
'complete',
`Successfully stitched ${videoBlobs.length} videos into ${(outputBlob.size / 1024 / 1024).toFixed(2)}MB file`,
100
);
return outputBlob;
} catch (error) {
const normalizedError =
error instanceof Error ? error : new Error(String(error));
console.error('Video stitching error:', normalizedError);
const errorProgress: StitchProgress = {
status: 'error',
message: `Error: ${normalizedError.message}`,
progress: 0,
error: normalizedError.message,
};
onProgress?.(errorProgress);
throw normalizedError;
}
}
interface UseStitchVideosReturn {
stitchVideos: (
videoBlobs: Blob[],
audioData?: AudioData | null,
onProgress?: (progress: StitchProgress) => void
) => Promise<Blob | null>;
progress: StitchProgress;
reset: () => void;
}
/**
* Hook for stitching multiple video blobs together sequentially
* Reads frames from each video and writes them to output in order
*/
export const useStitchVideos = (): UseStitchVideosReturn => {
const [progress, setProgress] = useState<StitchProgress>({
status: 'idle',
message: 'Ready',
progress: 0,
});
const stitchVideos = useCallback(
async (
videoBlobs: Blob[],
audioData?: AudioData | null,
onProgress?: (progress: StitchProgress) => void
): Promise<Blob | null> => {
try {
setProgress({
status: 'processing',
message: 'Initializing...',
progress: 0,
});
const result = await stitchVideosAsync(videoBlobs, audioData, (p) => {
setProgress(p);
onProgress?.(p);
});
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Video stitching failed';
setProgress({
status: 'error',
message: errorMessage,
progress: 0,
error: errorMessage,
});
return null;
}
},
[]
);
const reset = useCallback(() => {
setProgress({
status: 'idle',
message: 'Ready',
progress: 0,
});
}, []);
return {
stitchVideos,
progress,
reset,
};
};
Loading…
Cancel
Save